From ba87d734dc5db81780538c92097a70186053faad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:35:21 +0000 Subject: [PATCH 1/2] Initial plan From bd5ae6b6aa1f5fde64cdcf2d276cc25eeae31042 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:43:12 +0000 Subject: [PATCH 2/2] feat: add missing service contract interfaces for all CoreServiceName services Add 11 new contract interfaces with co-located tests: - IMetadataService (metadata) - IAuthService (auth) - IAutomationService (automation) - IGraphQLService (graphql) - IAnalyticsService (analytics) - IRealtimeService (realtime) - IJobService (job) - IAIService (ai) - II18nService (i18n) - IUIService (ui) - IWorkflowService (workflow) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../spec/src/contracts/ai-service.test.ts | 94 ++++++++++++ packages/spec/src/contracts/ai-service.ts | 86 +++++++++++ .../src/contracts/analytics-service.test.ts | 91 ++++++++++++ .../spec/src/contracts/analytics-service.ts | 94 ++++++++++++ .../spec/src/contracts/auth-service.test.ts | 74 ++++++++++ packages/spec/src/contracts/auth-service.ts | 87 ++++++++++++ .../src/contracts/automation-service.test.ts | 74 ++++++++++ .../spec/src/contracts/automation-service.ts | 73 ++++++++++ .../src/contracts/graphql-service.test.ts | 82 +++++++++++ .../spec/src/contracts/graphql-service.ts | 64 +++++++++ .../spec/src/contracts/i18n-service.test.ts | 88 ++++++++++++ packages/spec/src/contracts/i18n-service.ts | 57 ++++++++ packages/spec/src/contracts/index.ts | 11 ++ .../spec/src/contracts/job-service.test.ts | 111 +++++++++++++++ packages/spec/src/contracts/job-service.ts | 90 ++++++++++++ .../src/contracts/metadata-service.test.ts | 88 ++++++++++++ .../spec/src/contracts/metadata-service.ts | 64 +++++++++ .../src/contracts/realtime-service.test.ts | 97 +++++++++++++ .../spec/src/contracts/realtime-service.ts | 75 ++++++++++ .../spec/src/contracts/ui-service.test.ts | 83 +++++++++++ packages/spec/src/contracts/ui-service.ts | 57 ++++++++ .../src/contracts/workflow-service.test.ts | 134 ++++++++++++++++++ .../spec/src/contracts/workflow-service.ts | 87 ++++++++++++ 23 files changed, 1861 insertions(+) create mode 100644 packages/spec/src/contracts/ai-service.test.ts create mode 100644 packages/spec/src/contracts/ai-service.ts create mode 100644 packages/spec/src/contracts/analytics-service.test.ts create mode 100644 packages/spec/src/contracts/analytics-service.ts create mode 100644 packages/spec/src/contracts/auth-service.test.ts create mode 100644 packages/spec/src/contracts/auth-service.ts create mode 100644 packages/spec/src/contracts/automation-service.test.ts create mode 100644 packages/spec/src/contracts/automation-service.ts create mode 100644 packages/spec/src/contracts/graphql-service.test.ts create mode 100644 packages/spec/src/contracts/graphql-service.ts create mode 100644 packages/spec/src/contracts/i18n-service.test.ts create mode 100644 packages/spec/src/contracts/i18n-service.ts create mode 100644 packages/spec/src/contracts/job-service.test.ts create mode 100644 packages/spec/src/contracts/job-service.ts create mode 100644 packages/spec/src/contracts/metadata-service.test.ts create mode 100644 packages/spec/src/contracts/metadata-service.ts create mode 100644 packages/spec/src/contracts/realtime-service.test.ts create mode 100644 packages/spec/src/contracts/realtime-service.ts create mode 100644 packages/spec/src/contracts/ui-service.test.ts create mode 100644 packages/spec/src/contracts/ui-service.ts create mode 100644 packages/spec/src/contracts/workflow-service.test.ts create mode 100644 packages/spec/src/contracts/workflow-service.ts diff --git a/packages/spec/src/contracts/ai-service.test.ts b/packages/spec/src/contracts/ai-service.test.ts new file mode 100644 index 0000000000..6a9c1a3f65 --- /dev/null +++ b/packages/spec/src/contracts/ai-service.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import type { IAIService, AIMessage, AIResult } from './ai-service'; + +describe('AI Service Contract', () => { + it('should allow a minimal IAIService implementation with required methods', () => { + const service: IAIService = { + chat: async (_messages, _options?) => ({ content: '' }), + complete: async (_prompt, _options?) => ({ content: '' }), + }; + + expect(typeof service.chat).toBe('function'); + expect(typeof service.complete).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IAIService = { + chat: async () => ({ content: '' }), + complete: async () => ({ content: '' }), + embed: async () => [[]], + listModels: async () => [], + }; + + expect(service.embed).toBeDefined(); + expect(service.listModels).toBeDefined(); + }); + + it('should generate a chat completion', async () => { + const service: IAIService = { + chat: async (messages): Promise => { + const lastMessage = messages[messages.length - 1]; + return { + content: `Echo: ${lastMessage.content}`, + model: 'test-model', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + }; + }, + complete: async () => ({ content: '' }), + }; + + const messages: AIMessage[] = [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Hello' }, + ]; + + const result = await service.chat(messages); + expect(result.content).toBe('Echo: Hello'); + expect(result.model).toBe('test-model'); + expect(result.usage?.totalTokens).toBe(15); + }); + + it('should generate a text completion', async () => { + const service: IAIService = { + chat: async () => ({ content: '' }), + complete: async (prompt, options?): Promise => ({ + content: `Completed: ${prompt}`, + model: options?.model ?? 'default', + }), + }; + + const result = await service.complete('The sky is', { model: 'gpt-4', maxTokens: 50 }); + expect(result.content).toContain('The sky is'); + expect(result.model).toBe('gpt-4'); + }); + + it('should generate embeddings', async () => { + const service: IAIService = { + chat: async () => ({ content: '' }), + complete: async () => ({ content: '' }), + embed: async (input) => { + const texts = Array.isArray(input) ? input : [input]; + return texts.map(() => [0.1, 0.2, 0.3]); + }, + }; + + const embeddings = await service.embed!('Hello world'); + expect(embeddings).toHaveLength(1); + expect(embeddings[0]).toEqual([0.1, 0.2, 0.3]); + + const batch = await service.embed!(['Hello', 'World']); + expect(batch).toHaveLength(2); + }); + + it('should list available models', async () => { + const service: IAIService = { + chat: async () => ({ content: '' }), + complete: async () => ({ content: '' }), + listModels: async () => ['gpt-4', 'gpt-3.5-turbo', 'claude-3-sonnet'], + }; + + const models = await service.listModels!(); + expect(models).toHaveLength(3); + expect(models).toContain('gpt-4'); + }); +}); diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts new file mode 100644 index 0000000000..563a28e85d --- /dev/null +++ b/packages/spec/src/contracts/ai-service.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IAIService - AI Engine Service Contract + * + * Defines the interface for AI capabilities (NLQ, chat, suggestions, embeddings) + * in ObjectStack. Concrete implementations (OpenAI, Anthropic, Ollama, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete AI/LLM provider implementations. + * + * Aligned with CoreServiceName 'ai' in core-services.zod.ts. + */ + +/** + * A chat message in a conversation + */ +export interface AIMessage { + /** Message role */ + role: 'system' | 'user' | 'assistant'; + /** Message content */ + content: string; +} + +/** + * Options for AI completion/chat requests + */ +export interface AIRequestOptions { + /** Model identifier to use */ + model?: string; + /** Sampling temperature (0-2) */ + temperature?: number; + /** Maximum tokens to generate */ + maxTokens?: number; + /** Stop sequences */ + stop?: string[]; +} + +/** + * Result of an AI completion/chat request + */ +export interface AIResult { + /** Generated text content */ + content: string; + /** Model used for generation */ + model?: string; + /** Token usage statistics */ + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + +export interface IAIService { + /** + * Generate a chat completion from a conversation + * @param messages - Array of conversation messages + * @param options - Optional request configuration + * @returns AI-generated response + */ + chat(messages: AIMessage[], options?: AIRequestOptions): Promise; + + /** + * Generate a text completion from a prompt + * @param prompt - Input prompt string + * @param options - Optional request configuration + * @returns AI-generated response + */ + complete(prompt: string, options?: AIRequestOptions): Promise; + + /** + * Generate embeddings for a text input + * @param input - Text or array of texts to embed + * @param model - Optional embedding model identifier + * @returns Array of embedding vectors + */ + embed?(input: string | string[], model?: string): Promise; + + /** + * List available models + * @returns Array of model identifiers + */ + listModels?(): Promise; +} diff --git a/packages/spec/src/contracts/analytics-service.test.ts b/packages/spec/src/contracts/analytics-service.test.ts new file mode 100644 index 0000000000..c077910a59 --- /dev/null +++ b/packages/spec/src/contracts/analytics-service.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import type { IAnalyticsService, AnalyticsResult, CubeMeta } from './analytics-service'; + +describe('Analytics Service Contract', () => { + it('should allow a minimal IAnalyticsService implementation with required methods', () => { + const service: IAnalyticsService = { + query: async (_query) => ({ rows: [], fields: [] }), + getMeta: async () => [], + }; + + expect(typeof service.query).toBe('function'); + expect(typeof service.getMeta).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IAnalyticsService = { + query: async () => ({ rows: [], fields: [] }), + getMeta: async () => [], + generateSql: async () => ({ sql: 'SELECT 1', params: [] }), + }; + + expect(service.generateSql).toBeDefined(); + }); + + it('should execute an analytics query', async () => { + const service: IAnalyticsService = { + query: async (query): Promise => ({ + rows: [ + { 'orders.status': 'active', 'orders.count': 42 }, + { 'orders.status': 'closed', 'orders.count': 18 }, + ], + fields: [ + { name: 'orders.status', type: 'string' }, + { name: 'orders.count', type: 'number' }, + ], + }), + getMeta: async () => [], + }; + + const result = await service.query({ + cube: 'orders', + measures: ['orders.count'], + dimensions: ['orders.status'], + }); + + expect(result.rows).toHaveLength(2); + expect(result.fields).toHaveLength(2); + expect(result.rows[0]['orders.count']).toBe(42); + }); + + it('should return cube metadata', async () => { + const cubes: CubeMeta[] = [{ + name: 'orders', + title: 'Orders', + measures: [{ name: 'orders.count', type: 'count' }], + dimensions: [{ name: 'orders.status', type: 'string' }], + }]; + + const service: IAnalyticsService = { + query: async () => ({ rows: [], fields: [] }), + getMeta: async (cubeName?) => { + if (cubeName) return cubes.filter(c => c.name === cubeName); + return cubes; + }, + }; + + const meta = await service.getMeta(); + expect(meta).toHaveLength(1); + expect(meta[0].name).toBe('orders'); + expect(meta[0].measures).toHaveLength(1); + }); + + it('should generate SQL without executing', async () => { + const service: IAnalyticsService = { + query: async () => ({ rows: [], fields: [] }), + getMeta: async () => [], + generateSql: async (query) => ({ + sql: `SELECT COUNT(*) FROM ${query.cube}`, + params: [], + }), + }; + + const result = await service.generateSql!({ + cube: 'orders', + measures: ['orders.count'], + }); + + expect(result.sql).toContain('orders'); + expect(result.params).toEqual([]); + }); +}); diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts new file mode 100644 index 0000000000..e93df29dc1 --- /dev/null +++ b/packages/spec/src/contracts/analytics-service.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IAnalyticsService - Analytics / BI Service Contract + * + * Defines the interface for analytical query execution and semantic layer + * metadata discovery in ObjectStack. Concrete implementations (Cube.js, custom, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete analytics engine implementations. + * + * Aligned with CoreServiceName 'analytics' in core-services.zod.ts. + */ + +/** + * An analytical query definition + */ +export interface AnalyticsQuery { + /** Target cube name */ + cube: string; + /** Measures to compute (e.g. ['orders.count', 'orders.totalRevenue']) */ + measures?: string[]; + /** Dimensions to group by (e.g. ['orders.status', 'orders.createdAt']) */ + dimensions?: string[]; + /** Filter conditions */ + filters?: Array<{ + member: string; + operator: string; + values?: string[]; + }>; + /** Time dimension configuration */ + timeDimensions?: Array<{ + dimension: string; + granularity?: string; + dateRange?: string | string[]; + }>; + /** Result limit */ + limit?: number; + /** Result offset */ + offset?: number; +} + +/** + * Analytics query result + */ +export interface AnalyticsResult { + /** Result rows */ + rows: Record[]; + /** Column metadata */ + fields: Array<{ + name: string; + type: string; + }>; + /** Generated SQL (if available) */ + sql?: string; +} + +/** + * Cube metadata for discovery + */ +export interface CubeMeta { + /** Cube name */ + name: string; + /** Human-readable title */ + title?: string; + /** Available measures */ + measures: Array<{ name: string; type: string; title?: string }>; + /** Available dimensions */ + dimensions: Array<{ name: string; type: string; title?: string }>; +} + +export interface IAnalyticsService { + /** + * Execute an analytical query + * @param query - The analytics query definition + * @returns Query results with rows and field metadata + */ + query(query: AnalyticsQuery): Promise; + + /** + * Get available cube metadata for discovery + * @param cubeName - Optional cube name to filter (returns all if omitted) + * @returns Array of cube metadata definitions + */ + getMeta(cubeName?: string): Promise; + + /** + * Generate SQL for a query without executing it (dry-run) + * @param query - The analytics query definition + * @returns Generated SQL string and parameters + */ + generateSql?(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }>; +} diff --git a/packages/spec/src/contracts/auth-service.test.ts b/packages/spec/src/contracts/auth-service.test.ts new file mode 100644 index 0000000000..cc46be43f5 --- /dev/null +++ b/packages/spec/src/contracts/auth-service.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import type { IAuthService, AuthResult, AuthUser } from './auth-service'; + +describe('Auth Service Contract', () => { + it('should allow a minimal IAuthService implementation with required methods', () => { + const service: IAuthService = { + handleRequest: async (_request) => new Response('OK'), + verify: async (_token) => ({ success: false }), + }; + + expect(typeof service.handleRequest).toBe('function'); + expect(typeof service.verify).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IAuthService = { + handleRequest: async () => new Response('OK'), + verify: async () => ({ success: false }), + logout: async (_sessionId) => {}, + getCurrentUser: async (_request) => undefined, + }; + + expect(service.logout).toBeDefined(); + expect(service.getCurrentUser).toBeDefined(); + }); + + it('should verify a valid token', async () => { + const validUser: AuthUser = { id: 'u1', email: 'alice@test.com', name: 'Alice' }; + + const service: IAuthService = { + handleRequest: async () => new Response('OK'), + verify: async (token): Promise => { + if (token === 'valid-token') { + return { + success: true, + user: validUser, + session: { id: 's1', userId: 'u1', expiresAt: '2099-01-01T00:00:00Z' }, + }; + } + return { success: false, error: 'Invalid token' }; + }, + }; + + const result = await service.verify('valid-token'); + expect(result.success).toBe(true); + expect(result.user?.email).toBe('alice@test.com'); + expect(result.session?.userId).toBe('u1'); + }); + + it('should reject an invalid token', async () => { + const service: IAuthService = { + handleRequest: async () => new Response('OK'), + verify: async (_token) => ({ success: false, error: 'Invalid token' }), + }; + + const result = await service.verify('bad-token'); + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid token'); + }); + + it('should handle logout', async () => { + const sessions = new Set(['s1', 's2']); + + const service: IAuthService = { + handleRequest: async () => new Response('OK'), + verify: async () => ({ success: true }), + logout: async (sessionId) => { sessions.delete(sessionId); }, + }; + + await service.logout!('s1'); + expect(sessions.has('s1')).toBe(false); + expect(sessions.has('s2')).toBe(true); + }); +}); diff --git a/packages/spec/src/contracts/auth-service.ts b/packages/spec/src/contracts/auth-service.ts new file mode 100644 index 0000000000..3fd3964059 --- /dev/null +++ b/packages/spec/src/contracts/auth-service.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IAuthService - Authentication Service Contract + * + * Defines the interface for authentication and session management in ObjectStack. + * Concrete implementations (better-auth, custom, LDAP, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete auth provider implementations. + * + * Aligned with CoreServiceName 'auth' in core-services.zod.ts. + */ + +/** + * Authenticated session user information + */ +export interface AuthUser { + /** User identifier */ + id: string; + /** Email address */ + email: string; + /** Display name */ + name: string; + /** Assigned role identifiers */ + roles?: string[]; + /** Current tenant identifier (multi-tenant) */ + tenantId?: string; +} + +/** + * Active session information + */ +export interface AuthSession { + /** Session identifier */ + id: string; + /** Associated user identifier */ + userId: string; + /** Session expiry (ISO 8601) */ + expiresAt: string; + /** Bearer token (if not using cookies) */ + token?: string; +} + +/** + * Authentication result returned by login/verify operations + */ +export interface AuthResult { + /** Whether authentication succeeded */ + success: boolean; + /** Authenticated user (if success) */ + user?: AuthUser; + /** Active session (if success) */ + session?: AuthSession; + /** Error message (if failure) */ + error?: string; +} + +export interface IAuthService { + /** + * Handle an incoming HTTP authentication request + * @param request - Standard Request object + * @returns Standard Response object + */ + handleRequest(request: Request): Promise; + + /** + * Verify a session token or cookie and return the user + * @param token - Bearer token or session identifier + * @returns Auth result with user and session if valid + */ + verify(token: string): Promise; + + /** + * Invalidate a session (logout) + * @param sessionId - Session identifier to invalidate + */ + logout?(sessionId: string): Promise; + + /** + * Get the current user from a request + * @param request - Standard Request object + * @returns Authenticated user or undefined + */ + getCurrentUser?(request: Request): Promise; +} diff --git a/packages/spec/src/contracts/automation-service.test.ts b/packages/spec/src/contracts/automation-service.test.ts new file mode 100644 index 0000000000..17a6d6d74a --- /dev/null +++ b/packages/spec/src/contracts/automation-service.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import type { IAutomationService, AutomationResult } from './automation-service'; + +describe('Automation Service Contract', () => { + it('should allow a minimal IAutomationService implementation with required methods', () => { + const service: IAutomationService = { + execute: async (_flowName, _context?) => ({ success: true }), + listFlows: async () => [], + }; + + expect(typeof service.execute).toBe('function'); + expect(typeof service.listFlows).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IAutomationService = { + execute: async () => ({ success: true }), + listFlows: async () => [], + registerFlow: (_name, _definition) => {}, + unregisterFlow: (_name) => {}, + }; + + expect(service.registerFlow).toBeDefined(); + expect(service.unregisterFlow).toBeDefined(); + }); + + it('should execute a flow successfully', async () => { + const service: IAutomationService = { + execute: async (flowName, context?): Promise => { + return { + success: true, + output: { flowName, recordId: (context?.record as any)?.id }, + durationMs: 42, + }; + }, + listFlows: async () => ['send_welcome_email', 'update_status'], + }; + + const result = await service.execute('send_welcome_email', { + record: { id: 'rec-1', name: 'Alice' }, + object: 'contact', + event: 'on_create', + }); + + expect(result.success).toBe(true); + expect(result.output).toEqual({ flowName: 'send_welcome_email', recordId: 'rec-1' }); + expect(result.durationMs).toBe(42); + }); + + it('should handle execution failures', async () => { + const service: IAutomationService = { + execute: async () => ({ + success: false, + error: 'Flow step 3 failed: timeout', + }), + listFlows: async () => [], + }; + + const result = await service.execute('broken_flow'); + expect(result.success).toBe(false); + expect(result.error).toContain('timeout'); + }); + + it('should list registered flows', async () => { + const service: IAutomationService = { + execute: async () => ({ success: true }), + listFlows: async () => ['onboarding_flow', 'approval_flow', 'cleanup_flow'], + }; + + const flows = await service.listFlows(); + expect(flows).toHaveLength(3); + expect(flows).toContain('approval_flow'); + }); +}); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts new file mode 100644 index 0000000000..d2670daa6f --- /dev/null +++ b/packages/spec/src/contracts/automation-service.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IAutomationService - Automation Service Contract + * + * Defines the interface for flow/script execution in ObjectStack. + * Concrete implementations (Flow Engine, Script Runner, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete automation engine implementations. + * + * Aligned with CoreServiceName 'automation' in core-services.zod.ts. + */ + +/** + * Context passed to a flow/script execution + */ +export interface AutomationContext { + /** Record that triggered the automation (if applicable) */ + record?: Record; + /** Object name the record belongs to */ + object?: string; + /** Trigger event type (e.g. 'on_create', 'on_update') */ + event?: string; + /** User who triggered the automation */ + userId?: string; + /** Additional contextual data */ + params?: Record; +} + +/** + * Result of an automation execution + */ +export interface AutomationResult { + /** Whether the automation completed successfully */ + success: boolean; + /** Output data from the automation */ + output?: unknown; + /** Error message if execution failed */ + error?: string; + /** Execution duration in milliseconds */ + durationMs?: number; +} + +export interface IAutomationService { + /** + * Execute a named flow or script + * @param flowName - Flow/script identifier (snake_case) + * @param context - Execution context with trigger data + * @returns Automation result + */ + execute(flowName: string, context?: AutomationContext): Promise; + + /** + * List all registered automation flows + * @returns Array of flow names + */ + listFlows(): Promise; + + /** + * Register a flow definition + * @param name - Flow name (snake_case) + * @param definition - Flow definition object + */ + registerFlow?(name: string, definition: unknown): void; + + /** + * Unregister a flow by name + * @param name - Flow name (snake_case) + */ + unregisterFlow?(name: string): void; +} diff --git a/packages/spec/src/contracts/graphql-service.test.ts b/packages/spec/src/contracts/graphql-service.test.ts new file mode 100644 index 0000000000..e008160fbd --- /dev/null +++ b/packages/spec/src/contracts/graphql-service.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import type { IGraphQLService, GraphQLRequest, GraphQLResponse } from './graphql-service'; + +describe('GraphQL Service Contract', () => { + it('should allow a minimal IGraphQLService implementation with required methods', () => { + const service: IGraphQLService = { + execute: async (_request, _context?) => ({ data: null }), + }; + + expect(typeof service.execute).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IGraphQLService = { + execute: async () => ({ data: null }), + handleRequest: async (_request) => new Response('OK'), + getSchema: () => 'type Query { hello: String }', + }; + + expect(service.handleRequest).toBeDefined(); + expect(service.getSchema).toBeDefined(); + }); + + it('should execute a GraphQL query', async () => { + const service: IGraphQLService = { + execute: async (request): Promise => { + if (request.query.includes('hello')) { + return { data: { hello: 'world' } }; + } + return { data: null, errors: [{ message: 'Unknown query' }] }; + }, + }; + + const result = await service.execute({ query: '{ hello }' }); + expect(result.data).toEqual({ hello: 'world' }); + expect(result.errors).toBeUndefined(); + }); + + it('should return errors for invalid queries', async () => { + const service: IGraphQLService = { + execute: async (): Promise => ({ + data: null, + errors: [{ + message: 'Cannot query field "invalid"', + locations: [{ line: 1, column: 3 }], + }], + }), + }; + + const result = await service.execute({ query: '{ invalid }' }); + expect(result.errors).toHaveLength(1); + expect(result.errors![0].message).toContain('invalid'); + }); + + it('should support variables in queries', async () => { + const service: IGraphQLService = { + execute: async (request: GraphQLRequest): Promise => { + const id = request.variables?.id; + return { data: { user: { id, name: 'Alice' } } }; + }, + }; + + const result = await service.execute({ + query: 'query GetUser($id: ID!) { user(id: $id) { id name } }', + operationName: 'GetUser', + variables: { id: 'u1' }, + }); + + expect(result.data?.user).toEqual({ id: 'u1', name: 'Alice' }); + }); + + it('should return SDL schema', () => { + const service: IGraphQLService = { + execute: async () => ({ data: null }), + getSchema: () => 'type Query { users: [User] }\ntype User { id: ID! name: String }', + }; + + const schema = service.getSchema!(); + expect(schema).toContain('type Query'); + expect(schema).toContain('User'); + }); +}); diff --git a/packages/spec/src/contracts/graphql-service.ts b/packages/spec/src/contracts/graphql-service.ts new file mode 100644 index 0000000000..0306c30bde --- /dev/null +++ b/packages/spec/src/contracts/graphql-service.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IGraphQLService - GraphQL Service Contract + * + * Defines the interface for GraphQL schema and query execution in ObjectStack. + * Concrete implementations (Apollo, Yoga, Mercurius, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete GraphQL server implementations. + * + * Aligned with CoreServiceName 'graphql' in core-services.zod.ts. + */ + +/** + * A GraphQL execution request + */ +export interface GraphQLRequest { + /** GraphQL query or mutation string */ + query: string; + /** Operation name (when document contains multiple operations) */ + operationName?: string; + /** Variables for the operation */ + variables?: Record; +} + +/** + * A GraphQL execution response + */ +export interface GraphQLResponse { + /** Query result data */ + data?: Record | null; + /** Errors encountered during execution */ + errors?: Array<{ + message: string; + locations?: Array<{ line: number; column: number }>; + path?: Array; + extensions?: Record; + }>; +} + +export interface IGraphQLService { + /** + * Execute a GraphQL query or mutation + * @param request - The GraphQL request + * @param context - Optional execution context (e.g. auth user) + * @returns GraphQL response with data and/or errors + */ + execute(request: GraphQLRequest, context?: Record): Promise; + + /** + * Handle an incoming HTTP request for GraphQL + * @param request - Standard Request object + * @returns Standard Response object + */ + handleRequest?(request: Request): Promise; + + /** + * Get the current GraphQL schema as SDL string + * @returns SDL schema string + */ + getSchema?(): string; +} diff --git a/packages/spec/src/contracts/i18n-service.test.ts b/packages/spec/src/contracts/i18n-service.test.ts new file mode 100644 index 0000000000..0d6e633a25 --- /dev/null +++ b/packages/spec/src/contracts/i18n-service.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import type { II18nService } from './i18n-service'; + +describe('I18n Service Contract', () => { + it('should allow a minimal II18nService implementation with required methods', () => { + const service: II18nService = { + t: (_key, _locale, _params?) => '', + getTranslations: (_locale) => ({}), + loadTranslations: (_locale, _translations) => {}, + getLocales: () => [], + }; + + expect(typeof service.t).toBe('function'); + expect(typeof service.getTranslations).toBe('function'); + expect(typeof service.loadTranslations).toBe('function'); + expect(typeof service.getLocales).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: II18nService = { + t: () => '', + getTranslations: () => ({}), + loadTranslations: () => {}, + getLocales: () => [], + getDefaultLocale: () => 'en', + setDefaultLocale: (_locale) => {}, + }; + + expect(service.getDefaultLocale).toBeDefined(); + expect(service.setDefaultLocale).toBeDefined(); + }); + + it('should translate a key', () => { + const translations = new Map>(); + translations.set('en', { 'objects.account.label': 'Account' }); + translations.set('zh-CN', { 'objects.account.label': '客户' }); + + const service: II18nService = { + t: (key, locale) => { + const bundle = translations.get(locale); + return (bundle?.[key] as string) ?? key; + }, + getTranslations: (locale) => translations.get(locale) ?? {}, + loadTranslations: (locale, data) => { translations.set(locale, data); }, + getLocales: () => Array.from(translations.keys()), + }; + + expect(service.t('objects.account.label', 'en')).toBe('Account'); + expect(service.t('objects.account.label', 'zh-CN')).toBe('客户'); + expect(service.t('missing.key', 'en')).toBe('missing.key'); + }); + + it('should load and retrieve translations', () => { + const store = new Map>(); + + const service: II18nService = { + t: (key, locale) => { + const bundle = store.get(locale); + return (bundle?.[key] as string) ?? key; + }, + getTranslations: (locale) => store.get(locale) ?? {}, + loadTranslations: (locale, data) => { store.set(locale, data); }, + getLocales: () => Array.from(store.keys()), + }; + + service.loadTranslations('ja', { greeting: 'こんにちは' }); + expect(service.getLocales()).toContain('ja'); + expect(service.getTranslations('ja')).toEqual({ greeting: 'こんにちは' }); + expect(service.t('greeting', 'ja')).toBe('こんにちは'); + }); + + it('should manage default locale', () => { + let defaultLocale = 'en'; + + const service: II18nService = { + t: () => '', + getTranslations: () => ({}), + loadTranslations: () => {}, + getLocales: () => ['en', 'zh-CN', 'ja'], + getDefaultLocale: () => defaultLocale, + setDefaultLocale: (locale) => { defaultLocale = locale; }, + }; + + expect(service.getDefaultLocale!()).toBe('en'); + service.setDefaultLocale!('zh-CN'); + expect(service.getDefaultLocale!()).toBe('zh-CN'); + }); +}); diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts new file mode 100644 index 0000000000..b73524a03d --- /dev/null +++ b/packages/spec/src/contracts/i18n-service.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * II18nService - Internationalization Service Contract + * + * Defines the interface for translation and locale management in ObjectStack. + * Concrete implementations (i18next, custom, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete i18n library implementations. + * + * Aligned with CoreServiceName 'i18n' in core-services.zod.ts. + */ + +export interface II18nService { + /** + * Translate a message key for a given locale + * @param key - Translation key (e.g. 'objects.account.label') + * @param locale - BCP-47 locale code (e.g. 'en-US', 'zh-CN') + * @param params - Optional interpolation parameters + * @returns Translated string, or the key itself if not found + */ + t(key: string, locale: string, params?: Record): string; + + /** + * Get all translations for a locale + * @param locale - BCP-47 locale code + * @returns Translation data map + */ + getTranslations(locale: string): Record; + + /** + * Load translations for a locale + * @param locale - BCP-47 locale code + * @param translations - Translation key-value data + */ + loadTranslations(locale: string, translations: Record): void; + + /** + * List available locales + * @returns Array of BCP-47 locale codes + */ + getLocales(): string[]; + + /** + * Get the current default locale + * @returns BCP-47 locale code + */ + getDefaultLocale?(): string; + + /** + * Set the default locale + * @param locale - BCP-47 locale code + */ + setDefaultLocale?(locale: string): void; +} diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 90d98a04d6..06c07bf4eb 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -20,3 +20,14 @@ export * from './search-service.js'; export * from './queue-service.js'; export * from './notification-service.js'; export * from './storage-service.js'; +export * from './metadata-service.js'; +export * from './auth-service.js'; +export * from './automation-service.js'; +export * from './graphql-service.js'; +export * from './analytics-service.js'; +export * from './realtime-service.js'; +export * from './job-service.js'; +export * from './ai-service.js'; +export * from './i18n-service.js'; +export * from './ui-service.js'; +export * from './workflow-service.js'; diff --git a/packages/spec/src/contracts/job-service.test.ts b/packages/spec/src/contracts/job-service.test.ts new file mode 100644 index 0000000000..11497f2967 --- /dev/null +++ b/packages/spec/src/contracts/job-service.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import type { IJobService, JobHandler, JobExecution } from './job-service'; + +describe('Job Service Contract', () => { + it('should allow a minimal IJobService implementation with required methods', () => { + const service: IJobService = { + schedule: async (_name, _schedule, _handler) => {}, + cancel: async (_name) => {}, + trigger: async (_name, _data?) => {}, + }; + + expect(typeof service.schedule).toBe('function'); + expect(typeof service.cancel).toBe('function'); + expect(typeof service.trigger).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IJobService = { + schedule: async () => {}, + cancel: async () => {}, + trigger: async () => {}, + getExecutions: async (_name, _limit?) => [], + listJobs: async () => [], + }; + + expect(service.getExecutions).toBeDefined(); + expect(service.listJobs).toBeDefined(); + }); + + it('should schedule and trigger a job', async () => { + const jobs = new Map(); + + const service: IJobService = { + schedule: async (name, _schedule, handler) => { + jobs.set(name, handler); + }, + cancel: async (name) => { jobs.delete(name); }, + trigger: async (name, data?) => { + const handler = jobs.get(name); + if (handler) await handler({ jobId: name, data }); + }, + }; + + const executed: string[] = []; + await service.schedule( + 'sync_metadata', + { type: 'cron', expression: '0 0 * * *' }, + async (ctx) => { executed.push(ctx.jobId); }, + ); + + await service.trigger('sync_metadata'); + expect(executed).toEqual(['sync_metadata']); + }); + + it('should cancel a scheduled job', async () => { + const jobs = new Map(); + + const service: IJobService = { + schedule: async (name, _schedule, handler) => { jobs.set(name, handler); }, + cancel: async (name) => { jobs.delete(name); }, + trigger: async (name) => { + const handler = jobs.get(name); + if (handler) await handler({ jobId: name }); + }, + }; + + const executed: string[] = []; + await service.schedule('cleanup', { type: 'interval', intervalMs: 60000 }, async (ctx) => { + executed.push(ctx.jobId); + }); + + await service.cancel('cleanup'); + await service.trigger('cleanup'); + expect(executed).toHaveLength(0); + }); + + it('should return job executions', async () => { + const executions: JobExecution[] = [ + { jobId: 'sync', status: 'success', startedAt: '2025-01-01T00:00:00Z', completedAt: '2025-01-01T00:00:05Z', durationMs: 5000 }, + { jobId: 'sync', status: 'failed', startedAt: '2025-01-02T00:00:00Z', error: 'Connection timeout' }, + ]; + + const service: IJobService = { + schedule: async () => {}, + cancel: async () => {}, + trigger: async () => {}, + getExecutions: async (name, limit?) => { + const filtered = executions.filter(e => e.jobId === name); + return limit ? filtered.slice(0, limit) : filtered; + }, + }; + + const results = await service.getExecutions!('sync'); + expect(results).toHaveLength(2); + expect(results[0].status).toBe('success'); + expect(results[1].error).toBe('Connection timeout'); + }); + + it('should list registered jobs', async () => { + const service: IJobService = { + schedule: async () => {}, + cancel: async () => {}, + trigger: async () => {}, + listJobs: async () => ['sync_metadata', 'cleanup_logs', 'send_reports'], + }; + + const jobs = await service.listJobs!(); + expect(jobs).toHaveLength(3); + expect(jobs).toContain('cleanup_logs'); + }); +}); diff --git a/packages/spec/src/contracts/job-service.ts b/packages/spec/src/contracts/job-service.ts new file mode 100644 index 0000000000..6270b79593 --- /dev/null +++ b/packages/spec/src/contracts/job-service.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IJobService - Background Job Service Contract + * + * Defines the interface for scheduling and managing background jobs + * in ObjectStack. Concrete implementations (BullMQ, node-cron, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete job scheduler implementations. + * + * Aligned with CoreServiceName 'job' in core-services.zod.ts. + */ + +/** + * Schedule definition for a job + */ +export interface JobSchedule { + /** Schedule type */ + type: 'cron' | 'interval' | 'once'; + /** Cron expression (when type is 'cron') */ + expression?: string; + /** Timezone for cron (when type is 'cron') */ + timezone?: string; + /** Interval in milliseconds (when type is 'interval') */ + intervalMs?: number; + /** ISO 8601 datetime (when type is 'once') */ + at?: string; +} + +/** + * Job handler function + */ +export type JobHandler = (context: { jobId: string; data?: unknown }) => Promise; + +/** + * Status of a job execution + */ +export interface JobExecution { + /** Job identifier */ + jobId: string; + /** Execution status */ + status: 'running' | 'success' | 'failed' | 'timeout'; + /** Start time (ISO 8601) */ + startedAt: string; + /** Completion time (ISO 8601) */ + completedAt?: string; + /** Error message if failed */ + error?: string; + /** Duration in milliseconds */ + durationMs?: number; +} + +export interface IJobService { + /** + * Schedule a recurring or one-time job + * @param name - Job name (snake_case) + * @param schedule - Schedule configuration + * @param handler - Job handler function + */ + schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise; + + /** + * Cancel a scheduled job + * @param name - Job name + */ + cancel(name: string): Promise; + + /** + * Trigger a job to run immediately (outside its normal schedule) + * @param name - Job name + * @param data - Optional data to pass to the handler + */ + trigger(name: string, data?: unknown): Promise; + + /** + * Get the status of recent job executions + * @param name - Job name + * @param limit - Maximum number of executions to return + * @returns Array of job execution records + */ + getExecutions?(name: string, limit?: number): Promise; + + /** + * List all registered job names + * @returns Array of job names + */ + listJobs?(): Promise; +} diff --git a/packages/spec/src/contracts/metadata-service.test.ts b/packages/spec/src/contracts/metadata-service.test.ts new file mode 100644 index 0000000000..777108ad6a --- /dev/null +++ b/packages/spec/src/contracts/metadata-service.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import type { IMetadataService } from './metadata-service'; + +describe('Metadata Service Contract', () => { + it('should allow a minimal IMetadataService implementation with required methods', () => { + const service: IMetadataService = { + register: (_type, _definition) => {}, + get: (_type, _name) => undefined, + list: (_type) => [], + unregister: (_type, _name) => {}, + getObject: (_name) => undefined, + listObjects: () => [], + }; + + expect(typeof service.register).toBe('function'); + expect(typeof service.get).toBe('function'); + expect(typeof service.list).toBe('function'); + expect(typeof service.unregister).toBe('function'); + expect(typeof service.getObject).toBe('function'); + expect(typeof service.listObjects).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IMetadataService = { + register: () => {}, + get: () => undefined, + list: () => [], + unregister: () => {}, + getObject: () => undefined, + listObjects: () => [], + unregisterPackage: (_packageName) => {}, + }; + + expect(service.unregisterPackage).toBeDefined(); + }); + + it('should register and retrieve metadata items', () => { + const store = new Map>(); + + const service: IMetadataService = { + register: (type, definition) => { + if (!store.has(type)) store.set(type, new Map()); + const def = definition as { name: string }; + store.get(type)!.set(def.name, definition); + }, + get: (type, name) => store.get(type)?.get(name), + list: (type) => Array.from(store.get(type)?.values() ?? []), + unregister: (type, name) => { store.get(type)?.delete(name); }, + getObject: (name) => store.get('object')?.get(name), + listObjects: () => Array.from(store.get('object')?.values() ?? []), + }; + + const objectDef = { name: 'account', label: 'Account', fields: {} }; + service.register('object', objectDef); + + expect(service.get('object', 'account')).toEqual(objectDef); + expect(service.getObject('account')).toEqual(objectDef); + expect(service.listObjects()).toHaveLength(1); + + service.unregister('object', 'account'); + expect(service.get('object', 'account')).toBeUndefined(); + }); + + it('should list items by type', () => { + const store = new Map>(); + + const service: IMetadataService = { + register: (type, definition) => { + if (!store.has(type)) store.set(type, new Map()); + const def = definition as { name: string }; + store.get(type)!.set(def.name, definition); + }, + get: (type, name) => store.get(type)?.get(name), + list: (type) => Array.from(store.get(type)?.values() ?? []), + unregister: (type, name) => { store.get(type)?.delete(name); }, + getObject: (name) => store.get('object')?.get(name), + listObjects: () => Array.from(store.get('object')?.values() ?? []), + }; + + service.register('object', { name: 'account', label: 'Account' }); + service.register('object', { name: 'contact', label: 'Contact' }); + service.register('view', { name: 'account_list', label: 'Account List' }); + + expect(service.list('object')).toHaveLength(2); + expect(service.list('view')).toHaveLength(1); + expect(service.list('flow')).toHaveLength(0); + }); +}); diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts new file mode 100644 index 0000000000..7f88711d79 --- /dev/null +++ b/packages/spec/src/contracts/metadata-service.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IMetadataService - Metadata Service Contract + * + * Defines the interface for managing object/field definitions in ObjectStack. + * Concrete implementations (SchemaRegistry, Database-backed, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete metadata storage implementations. + * + * Aligned with CoreServiceName 'metadata' in core-services.zod.ts. + */ + +export interface IMetadataService { + /** + * Register a metadata item by type + * @param type - Metadata type (e.g. 'object', 'view', 'flow') + * @param definition - The metadata definition to register + */ + register(type: string, definition: unknown): void; + + /** + * Get a metadata item by type and name + * @param type - Metadata type + * @param name - Item name/identifier + * @returns The metadata definition, or undefined if not found + */ + get(type: string, name: string): unknown | undefined; + + /** + * List all metadata items of a given type + * @param type - Metadata type + * @returns Array of metadata definitions + */ + list(type: string): unknown[]; + + /** + * Unregister a metadata item by type and name + * @param type - Metadata type + * @param name - Item name/identifier + */ + unregister(type: string, name: string): void; + + /** + * Convenience: get an object definition by name + * @param name - Object name (snake_case) + * @returns The object definition, or undefined if not found + */ + getObject(name: string): unknown | undefined; + + /** + * Convenience: list all object definitions + * @returns Array of object definitions + */ + listObjects(): unknown[]; + + /** + * Unregister all metadata items from a specific package + * @param packageName - The package name whose items should be removed + */ + unregisterPackage?(packageName: string): void; +} diff --git a/packages/spec/src/contracts/realtime-service.test.ts b/packages/spec/src/contracts/realtime-service.test.ts new file mode 100644 index 0000000000..deff7b233b --- /dev/null +++ b/packages/spec/src/contracts/realtime-service.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import type { IRealtimeService, RealtimeEventPayload, RealtimeEventHandler } from './realtime-service'; + +describe('Realtime Service Contract', () => { + it('should allow a minimal IRealtimeService implementation with required methods', () => { + const service: IRealtimeService = { + publish: async (_event) => {}, + subscribe: async (_channel, _handler, _options?) => 'sub-1', + unsubscribe: async (_subscriptionId) => {}, + }; + + expect(typeof service.publish).toBe('function'); + expect(typeof service.subscribe).toBe('function'); + expect(typeof service.unsubscribe).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IRealtimeService = { + publish: async () => {}, + subscribe: async () => 'sub-1', + unsubscribe: async () => {}, + handleUpgrade: async (_request) => new Response('OK'), + }; + + expect(service.handleUpgrade).toBeDefined(); + }); + + it('should publish and receive events', async () => { + const handlers = new Map(); + let subCounter = 0; + + const service: IRealtimeService = { + publish: async (event) => { + for (const [, fns] of handlers) { + for (const fn of fns) await fn(event); + } + }, + subscribe: async (channel, handler) => { + const id = `sub-${++subCounter}`; + if (!handlers.has(channel)) handlers.set(channel, []); + handlers.get(channel)!.push(handler); + return id; + }, + unsubscribe: async () => {}, + }; + + const received: RealtimeEventPayload[] = []; + await service.subscribe('records', (event) => { received.push(event); }); + + await service.publish({ + type: 'record.created', + object: 'account', + payload: { id: 'acc-1', name: 'Acme' }, + timestamp: new Date().toISOString(), + }); + + expect(received).toHaveLength(1); + expect(received[0].type).toBe('record.created'); + expect(received[0].object).toBe('account'); + }); + + it('should unsubscribe from events', async () => { + const subs = new Map(); + let subCounter = 0; + + const service: IRealtimeService = { + publish: async (event) => { + for (const handler of subs.values()) await handler(event); + }, + subscribe: async (_channel, handler) => { + const id = `sub-${++subCounter}`; + subs.set(id, handler); + return id; + }, + unsubscribe: async (subscriptionId) => { subs.delete(subscriptionId); }, + }; + + const received: RealtimeEventPayload[] = []; + const subId = await service.subscribe('records', (event) => { received.push(event); }); + + await service.publish({ + type: 'record.updated', + payload: { id: '1' }, + timestamp: new Date().toISOString(), + }); + expect(received).toHaveLength(1); + + await service.unsubscribe(subId); + + await service.publish({ + type: 'record.deleted', + payload: { id: '2' }, + timestamp: new Date().toISOString(), + }); + expect(received).toHaveLength(1); // No new events after unsubscribe + }); +}); diff --git a/packages/spec/src/contracts/realtime-service.ts b/packages/spec/src/contracts/realtime-service.ts new file mode 100644 index 0000000000..70ddb5b05e --- /dev/null +++ b/packages/spec/src/contracts/realtime-service.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IRealtimeService - Realtime / PubSub Service Contract + * + * Defines the interface for realtime event subscription and publishing + * in ObjectStack. Concrete implementations (WebSocket, SSE, Socket.IO, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete realtime transport implementations. + * + * Aligned with CoreServiceName 'realtime' in core-services.zod.ts. + */ + +/** + * A realtime event payload + */ +export interface RealtimeEventPayload { + /** Event type (e.g. 'record.created', 'record.updated') */ + type: string; + /** Object name the event relates to */ + object?: string; + /** Event data */ + payload: Record; + /** Timestamp (ISO 8601) */ + timestamp: string; +} + +/** + * Handler function for realtime event subscriptions + */ +export type RealtimeEventHandler = (event: RealtimeEventPayload) => void | Promise; + +/** + * Subscription options for filtering events + */ +export interface RealtimeSubscriptionOptions { + /** Object name to filter events for */ + object?: string; + /** Event types to listen for */ + eventTypes?: string[]; + /** Additional filter conditions */ + filter?: Record; +} + +export interface IRealtimeService { + /** + * Publish an event to all subscribers + * @param event - The event to publish + */ + publish(event: RealtimeEventPayload): Promise; + + /** + * Subscribe to realtime events + * @param channel - Channel/topic name + * @param handler - Event handler function + * @param options - Optional subscription filters + * @returns Subscription identifier for unsubscribing + */ + subscribe(channel: string, handler: RealtimeEventHandler, options?: RealtimeSubscriptionOptions): Promise; + + /** + * Unsubscribe from a channel + * @param subscriptionId - Subscription identifier returned by subscribe() + */ + unsubscribe(subscriptionId: string): Promise; + + /** + * Handle an incoming HTTP upgrade request (WebSocket handshake) + * @param request - Standard Request object + * @returns Standard Response object + */ + handleUpgrade?(request: Request): Promise; +} diff --git a/packages/spec/src/contracts/ui-service.test.ts b/packages/spec/src/contracts/ui-service.test.ts new file mode 100644 index 0000000000..caecb988b8 --- /dev/null +++ b/packages/spec/src/contracts/ui-service.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import type { IUIService } from './ui-service'; + +describe('UI Service Contract', () => { + it('should allow a minimal IUIService implementation with required methods', () => { + const service: IUIService = { + getView: (_name) => undefined, + listViews: (_object?) => [], + }; + + expect(typeof service.getView).toBe('function'); + expect(typeof service.listViews).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IUIService = { + getView: () => undefined, + listViews: () => [], + getDashboard: (_name) => undefined, + listDashboards: () => [], + registerView: (_name, _definition) => {}, + registerDashboard: (_name, _definition) => {}, + }; + + expect(service.getDashboard).toBeDefined(); + expect(service.listDashboards).toBeDefined(); + expect(service.registerView).toBeDefined(); + expect(service.registerDashboard).toBeDefined(); + }); + + it('should register and retrieve views', () => { + const views = new Map(); + + const service: IUIService = { + getView: (name) => views.get(name), + listViews: (object?) => { + const all = Array.from(views.values()); + if (object) return all.filter((v: any) => v.object === object); + return all; + }, + registerView: (name, definition) => { views.set(name, definition); }, + }; + + const viewDef = { name: 'account_list', object: 'account', type: 'grid', columns: ['name', 'email'] }; + service.registerView!('account_list', viewDef); + + expect(service.getView('account_list')).toEqual(viewDef); + expect(service.listViews()).toHaveLength(1); + expect(service.listViews('account')).toHaveLength(1); + expect(service.listViews('contact')).toHaveLength(0); + }); + + it('should manage dashboards', () => { + const dashboards = new Map(); + + const service: IUIService = { + getView: () => undefined, + listViews: () => [], + getDashboard: (name) => dashboards.get(name), + listDashboards: () => Array.from(dashboards.values()), + registerDashboard: (name, definition) => { dashboards.set(name, definition); }, + }; + + service.registerDashboard!('sales_overview', { + name: 'sales_overview', + label: 'Sales Overview', + widgets: [{ type: 'chart', title: 'Revenue' }], + }); + + expect(service.getDashboard!('sales_overview')).toBeDefined(); + expect(service.listDashboards!()).toHaveLength(1); + expect(service.getDashboard!('missing')).toBeUndefined(); + }); + + it('should return undefined for non-existent views', () => { + const service: IUIService = { + getView: () => undefined, + listViews: () => [], + }; + + expect(service.getView('nonexistent')).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/contracts/ui-service.ts b/packages/spec/src/contracts/ui-service.ts new file mode 100644 index 0000000000..ca7bf8836c --- /dev/null +++ b/packages/spec/src/contracts/ui-service.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IUIService - UI Metadata Service Contract + * + * Defines the interface for managing UI metadata (views, dashboards, layouts) + * in ObjectStack. Concrete implementations (database-backed, in-memory, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete UI metadata storage implementations. + * + * Aligned with CoreServiceName 'ui' in core-services.zod.ts. + */ + +export interface IUIService { + /** + * Get a view definition by name + * @param name - View name (snake_case) + * @returns View definition, or undefined if not found + */ + getView(name: string): unknown | undefined; + + /** + * List view definitions, optionally filtered by object + * @param object - Optional object name to filter views for + * @returns Array of view definitions + */ + listViews(object?: string): unknown[]; + + /** + * Get a dashboard definition by name + * @param name - Dashboard name (snake_case) + * @returns Dashboard definition, or undefined if not found + */ + getDashboard?(name: string): unknown | undefined; + + /** + * List all dashboard definitions + * @returns Array of dashboard definitions + */ + listDashboards?(): unknown[]; + + /** + * Register a view definition + * @param name - View name (snake_case) + * @param definition - View definition object + */ + registerView?(name: string, definition: unknown): void; + + /** + * Register a dashboard definition + * @param name - Dashboard name (snake_case) + * @param definition - Dashboard definition object + */ + registerDashboard?(name: string, definition: unknown): void; +} diff --git a/packages/spec/src/contracts/workflow-service.test.ts b/packages/spec/src/contracts/workflow-service.test.ts new file mode 100644 index 0000000000..9050be560b --- /dev/null +++ b/packages/spec/src/contracts/workflow-service.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import type { IWorkflowService, WorkflowTransition, WorkflowTransitionResult, WorkflowStatus } from './workflow-service'; + +describe('Workflow Service Contract', () => { + it('should allow a minimal IWorkflowService implementation with required methods', () => { + const service: IWorkflowService = { + transition: async (_transition) => ({ success: true, currentState: 'approved' }), + getStatus: async (_object, _recordId) => ({ + recordId: '1', object: 'order', currentState: 'draft', availableTransitions: [], + }), + }; + + expect(typeof service.transition).toBe('function'); + expect(typeof service.getStatus).toBe('function'); + }); + + it('should allow a full implementation with optional methods', () => { + const service: IWorkflowService = { + transition: async () => ({ success: true }), + getStatus: async () => ({ + recordId: '1', object: 'order', currentState: 'draft', availableTransitions: [], + }), + getHistory: async () => [], + }; + + expect(service.getHistory).toBeDefined(); + }); + + it('should transition a record to a new state', async () => { + const states = new Map(); + states.set('order:ord-1', 'draft'); + + const allowedTransitions: Record = { + draft: ['submitted'], + submitted: ['approved', 'rejected'], + approved: ['completed'], + }; + + const service: IWorkflowService = { + transition: async (t): Promise => { + const key = `${t.object}:${t.recordId}`; + const current = states.get(key); + if (!current) return { success: false, error: 'Record not found' }; + + const allowed = allowedTransitions[current] ?? []; + if (!allowed.includes(t.targetState)) { + return { success: false, error: `Cannot transition from ${current} to ${t.targetState}` }; + } + + states.set(key, t.targetState); + return { success: true, currentState: t.targetState }; + }, + getStatus: async (object, recordId) => { + const key = `${object}:${recordId}`; + const currentState = states.get(key) ?? 'unknown'; + return { + recordId, object, currentState, + availableTransitions: allowedTransitions[currentState] ?? [], + }; + }, + }; + + const result = await service.transition({ + recordId: 'ord-1', + object: 'order', + targetState: 'submitted', + comment: 'Ready for review', + }); + + expect(result.success).toBe(true); + expect(result.currentState).toBe('submitted'); + + const status = await service.getStatus('order', 'ord-1'); + expect(status.currentState).toBe('submitted'); + expect(status.availableTransitions).toContain('approved'); + }); + + it('should reject invalid transitions', async () => { + const service: IWorkflowService = { + transition: async (t): Promise => ({ + success: false, + error: `Cannot transition to ${t.targetState}`, + }), + getStatus: async () => ({ + recordId: '1', object: 'order', currentState: 'draft', + availableTransitions: ['submitted'], + }), + }; + + const result = await service.transition({ + recordId: '1', + object: 'order', + targetState: 'completed', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('completed'); + }); + + it('should return transition history', async () => { + const service: IWorkflowService = { + transition: async () => ({ success: true }), + getStatus: async () => ({ + recordId: '1', object: 'order', currentState: 'approved', + availableTransitions: ['completed'], + }), + getHistory: async () => [ + { fromState: 'draft', toState: 'submitted', userId: 'u1', timestamp: '2025-01-01T00:00:00Z' }, + { fromState: 'submitted', toState: 'approved', userId: 'u2', comment: 'LGTM', timestamp: '2025-01-02T00:00:00Z' }, + ], + }; + + const history = await service.getHistory!('order', 'ord-1'); + expect(history).toHaveLength(2); + expect(history[0].fromState).toBe('draft'); + expect(history[1].comment).toBe('LGTM'); + }); + + it('should get workflow status with available transitions', async () => { + const service: IWorkflowService = { + transition: async () => ({ success: true }), + getStatus: async (_object, _recordId): Promise => ({ + recordId: 'ord-1', + object: 'order', + currentState: 'submitted', + availableTransitions: ['approved', 'rejected'], + }), + }; + + const status = await service.getStatus('order', 'ord-1'); + expect(status.currentState).toBe('submitted'); + expect(status.availableTransitions).toEqual(['approved', 'rejected']); + }); +}); diff --git a/packages/spec/src/contracts/workflow-service.ts b/packages/spec/src/contracts/workflow-service.ts new file mode 100644 index 0000000000..d5ef04e5e3 --- /dev/null +++ b/packages/spec/src/contracts/workflow-service.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IWorkflowService - Workflow State Machine Service Contract + * + * Defines the interface for workflow state management and approval processes + * in ObjectStack. Concrete implementations (state machine engines, BPMN, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete workflow engine implementations. + * + * Aligned with CoreServiceName 'workflow' in core-services.zod.ts. + */ + +/** + * A state transition request + */ +export interface WorkflowTransition { + /** Record identifier */ + recordId: string; + /** Object name the record belongs to */ + object: string; + /** Target state to transition to */ + targetState: string; + /** Optional comment for the transition */ + comment?: string; + /** User performing the transition */ + userId?: string; +} + +/** + * Result of a transition attempt + */ +export interface WorkflowTransitionResult { + /** Whether the transition succeeded */ + success: boolean; + /** The new current state (if success) */ + currentState?: string; + /** Error or rejection reason (if failure) */ + error?: string; +} + +/** + * Status of a workflow instance + */ +export interface WorkflowStatus { + /** Record identifier */ + recordId: string; + /** Object name */ + object: string; + /** Current state */ + currentState: string; + /** Available transitions from the current state */ + availableTransitions: string[]; +} + +export interface IWorkflowService { + /** + * Transition a record to a new workflow state + * @param transition - Transition request details + * @returns Transition result + */ + transition(transition: WorkflowTransition): Promise; + + /** + * Get the current workflow status of a record + * @param object - Object name + * @param recordId - Record identifier + * @returns Current workflow status with available transitions + */ + getStatus(object: string, recordId: string): Promise; + + /** + * Get transition history for a record + * @param object - Object name + * @param recordId - Record identifier + * @returns Array of historical transitions + */ + getHistory?(object: string, recordId: string): Promise>; +}