diff --git a/content/docs/references/system/data-engine.mdx b/content/docs/references/system/data-engine.mdx new file mode 100644 index 000000000..9c7c50f3b --- /dev/null +++ b/content/docs/references/system/data-engine.mdx @@ -0,0 +1,55 @@ +--- +title: Data Engine +description: Data Engine protocol schemas +--- + +# Data Engine + + +**Source:** `packages/spec/src/system/data-engine.zod.ts` + + +## TypeScript Usage + +```typescript +import { DataEngineSchema, DataEngineFilterSchema, DataEngineQueryOptionsSchema } from '@objectstack/spec/system'; +import type { DataEngine, DataEngineFilter, DataEngineQueryOptions } from '@objectstack/spec/system'; + +// Validate data +const result = DataEngineSchema.parse(data); +``` + +--- + +## DataEngine + +Data Engine Interface + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | + +--- + +## DataEngineFilter + +Data Engine query filter conditions + +--- + +## DataEngineQueryOptions + +Query options for IDataEngine.find() operations + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **filter** | `Record` | optional | Data Engine query filter conditions | +| **select** | `string[]` | optional | | +| **sort** | `Record>` | optional | | +| **limit** | `number` | optional | | +| **skip** | `number` | optional | | +| **top** | `number` | optional | | + diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx index fa6bcc251..dd4607dd2 100644 --- a/content/docs/references/system/index.mdx +++ b/content/docs/references/system/index.mdx @@ -10,6 +10,7 @@ This section contains all protocol schemas for the system layer of ObjectStack. + diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index 5d6a36d7a..09e989f8b 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -3,6 +3,7 @@ "pages": [ "audit", "context", + "data-engine", "datasource", "driver", "events", diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 0c301d294..1aecef495 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -1,6 +1,7 @@ import { QueryAST, HookContext } from '@objectstack/spec/data'; import { ObjectStackManifest } from '@objectstack/spec/system'; import { DriverInterface, DriverOptions } from '@objectstack/spec/system'; +import { IDataEngine, DataEngineQueryOptions } from '@objectstack/spec/system'; import { SchemaRegistry } from './registry'; // Export Registry for consumers @@ -20,8 +21,10 @@ export interface PluginContext { /** * ObjectQL Engine + * + * Implements the IDataEngine interface for data persistence. */ -export class ObjectQL { +export class ObjectQL implements IDataEngine { private drivers = new Map(); private defaultDriver: string | null = null; @@ -211,27 +214,57 @@ export class ObjectQL { } // ============================================ - // Data Access Methods + // Data Access Methods (IDataEngine Interface) // ============================================ - async find(object: string, query: any = {}, options?: DriverOptions) { + /** + * Find records matching a query (IDataEngine interface) + * + * @param object - Object name + * @param query - Query options (IDataEngine format) + * @returns Promise resolving to array of records + */ + async find(object: string, query?: DataEngineQueryOptions): Promise { const driver = this.getDriver(object); - // Normalize QueryAST - let ast: QueryAST; - if (query.where || query.fields || query.orderBy || query.limit) { - ast = { object, ...query } as QueryAST; - } else { - ast = { object, where: query } as QueryAST; + // Convert DataEngineQueryOptions to QueryAST + let ast: QueryAST = { object }; + + if (query) { + // Map DataEngineQueryOptions to QueryAST + if (query.filter) { + ast.where = query.filter; + } + if (query.select) { + ast.fields = query.select; + } + if (query.sort) { + // Convert sort Record to orderBy array + // sort: { createdAt: -1, name: 'asc' } => orderBy: [{ field: 'createdAt', order: 'desc' }, { field: 'name', order: 'asc' }] + ast.orderBy = Object.entries(query.sort).map(([field, order]) => ({ + field, + order: (order === -1 || order === 'desc') ? 'desc' : 'asc' + })); + } + // Handle both limit and top (top takes precedence) + if (query.top !== undefined) { + ast.limit = query.top; + } else if (query.limit !== undefined) { + ast.limit = query.limit; + } + if (query.skip !== undefined) { + ast.offset = query.skip; + } } + // Set default limit if not specified if (ast.limit === undefined) ast.limit = 100; // Trigger Before Hook const hookContext: HookContext = { object, event: 'beforeFind', - input: { ast, options }, // Hooks can modify AST here + input: { ast, options: undefined }, ql: this }; await this.triggerHooks('beforeFind', hookContext); @@ -275,7 +308,14 @@ export class ObjectQL { return driver.findOne(object, ast, options); } - async insert(object: string, data: Record, options?: DriverOptions) { + /** + * Insert a new record (IDataEngine interface) + * + * @param object - Object name + * @param data - Data to insert + * @returns Promise resolving to the created record + */ + async insert(object: string, data: any): Promise { const driver = this.getDriver(object); // 1. Get Schema @@ -290,7 +330,7 @@ export class ObjectQL { const hookContext: HookContext = { object, event: 'beforeInsert', - input: { data, options }, + input: { data, options: undefined }, ql: this }; await this.triggerHooks('beforeInsert', hookContext); @@ -306,13 +346,21 @@ export class ObjectQL { return hookContext.result; } - async update(object: string, id: string | number, data: Record, options?: DriverOptions) { + /** + * Update a record by ID (IDataEngine interface) + * + * @param object - Object name + * @param id - Record ID + * @param data - Updated data + * @returns Promise resolving to the updated record + */ + async update(object: string, id: any, data: any): Promise { const driver = this.getDriver(object); const hookContext: HookContext = { object, event: 'beforeUpdate', - input: { id, data, options }, + input: { id, data, options: undefined }, ql: this }; await this.triggerHooks('beforeUpdate', hookContext); @@ -326,13 +374,20 @@ export class ObjectQL { return hookContext.result; } - async delete(object: string, id: string | number, options?: DriverOptions) { + /** + * Delete a record by ID (IDataEngine interface) + * + * @param object - Object name + * @param id - Record ID + * @returns Promise resolving to true if deleted, false otherwise + */ + async delete(object: string, id: any): Promise { const driver = this.getDriver(object); const hookContext: HookContext = { object, event: 'beforeDelete', - input: { id, options }, + input: { id, options: undefined }, ql: this }; await this.triggerHooks('beforeDelete', hookContext); @@ -343,6 +398,7 @@ export class ObjectQL { hookContext.result = result; await this.triggerHooks('afterDelete', hookContext); + // Driver.delete() already returns boolean per DriverInterface spec return hookContext.result; } } diff --git a/packages/runtime/src/interfaces/data-engine.ts b/packages/runtime/src/interfaces/data-engine.ts index afe3c4e02..458567615 100644 --- a/packages/runtime/src/interfaces/data-engine.ts +++ b/packages/runtime/src/interfaces/data-engine.ts @@ -1,122 +1,8 @@ /** * IDataEngine - Standard Data Engine Interface * - * Abstract interface for data persistence capabilities. - * This allows plugins to interact with data engines without knowing - * the underlying implementation (SQL, MongoDB, Memory, etc.). - * - * Follows Dependency Inversion Principle - plugins depend on this interface, - * not on concrete database implementations. - */ - -/** - * Query filter conditions - */ -export interface QueryFilter { - [field: string]: any; -} - -/** - * Query options for find operations + * Re-exports the data engine interface from the spec package. + * This provides backward compatibility for imports from @objectstack/runtime. */ -export interface QueryOptions { - /** Filter conditions */ - filter?: QueryFilter; - /** Fields to select */ - select?: string[]; - /** Sort order */ - sort?: Record; - /** Limit number of results (alternative name for top, used by some drivers) */ - limit?: number; - /** Skip number of results (for pagination) */ - skip?: number; - /** Maximum number of results (OData-style, takes precedence over limit if both specified) */ - top?: number; -} -/** - * IDataEngine - Data persistence capability interface - * - * Defines the contract for data engine implementations. - * Concrete implementations (ObjectQL, Prisma, TypeORM) should implement this interface. - */ -export interface IDataEngine { - /** - * Insert a new record - * - * @param objectName - Name of the object/table (e.g., 'user', 'order') - * @param data - Data to insert - * @returns Promise resolving to the created record (including generated ID) - * - * @example - * ```ts - * const user = await engine.insert('user', { - * name: 'John Doe', - * email: 'john@example.com' - * }); - * console.log(user.id); // Auto-generated ID - * ``` - */ - insert(objectName: string, data: any): Promise; - - /** - * Find records matching a query - * - * @param objectName - Name of the object/table - * @param query - Query conditions (optional) - * @returns Promise resolving to an array of matching records - * - * @example - * ```ts - * // Find all users - * const allUsers = await engine.find('user'); - * - * // Find with filter - * const activeUsers = await engine.find('user', { - * filter: { status: 'active' } - * }); - * - * // Find with limit and sort - * const recentUsers = await engine.find('user', { - * sort: { createdAt: -1 }, - * limit: 10 - * }); - * ``` - */ - find(objectName: string, query?: QueryOptions): Promise; - - /** - * Update a record by ID - * - * @param objectName - Name of the object/table - * @param id - Record ID - * @param data - Updated data (partial update) - * @returns Promise resolving to the updated record - * - * @example - * ```ts - * const updatedUser = await engine.update('user', '123', { - * name: 'Jane Doe', - * email: 'jane@example.com' - * }); - * ``` - */ - update(objectName: string, id: any, data: any): Promise; - - /** - * Delete a record by ID - * - * @param objectName - Name of the object/table - * @param id - Record ID - * @returns Promise resolving to true if deleted, false otherwise - * - * @example - * ```ts - * const deleted = await engine.delete('user', '123'); - * if (deleted) { - * console.log('User deleted successfully'); - * } - * ``` - */ - delete(objectName: string, id: any): Promise; -} +export type { IDataEngine, DataEngineQueryOptions, DataEngineFilter } from '@objectstack/spec/system'; diff --git a/packages/runtime/src/objectql-plugin.ts b/packages/runtime/src/objectql-plugin.ts index 480c0d520..68beed75d 100644 --- a/packages/runtime/src/objectql-plugin.ts +++ b/packages/runtime/src/objectql-plugin.ts @@ -44,9 +44,13 @@ export class ObjectQLPlugin implements Plugin { * Init phase - Register ObjectQL as a service */ async init(ctx: PluginContext) { - // Register ObjectQL engine as a service + // Register ObjectQL engine as 'objectql' service (legacy name) ctx.registerService('objectql', this.ql); - ctx.logger.log('[ObjectQLPlugin] ObjectQL engine registered as service'); + + // Register ObjectQL engine as 'data-engine' service (IDataEngine interface) + ctx.registerService('data-engine', this.ql); + + ctx.logger.log('[ObjectQLPlugin] ObjectQL engine registered as services: objectql, data-engine'); } /** diff --git a/packages/runtime/src/test-interfaces.ts b/packages/runtime/src/test-interfaces.ts index 47e3f280f..74735dfb1 100644 --- a/packages/runtime/src/test-interfaces.ts +++ b/packages/runtime/src/test-interfaces.ts @@ -5,7 +5,7 @@ * and IDataEngine interfaces without depending on concrete implementations. */ -import { IHttpServer, IDataEngine, RouteHandler, IHttpRequest, IHttpResponse, Middleware, QueryOptions } from './index.js'; +import { IHttpServer, IDataEngine, RouteHandler, IHttpRequest, IHttpResponse, Middleware, DataEngineQueryOptions } from './index.js'; /** * Example: Mock HTTP Server Plugin @@ -77,7 +77,7 @@ class MockDataEngine implements IDataEngine { return record; } - async find(objectName: string, query?: QueryOptions): Promise { + async find(objectName: string, query?: DataEngineQueryOptions): Promise { const objectStore = this.store.get(objectName); if (!objectStore) { return []; diff --git a/packages/spec/json-schema/system/DataEngine.json b/packages/spec/json-schema/system/DataEngine.json new file mode 100644 index 000000000..d587be050 --- /dev/null +++ b/packages/spec/json-schema/system/DataEngine.json @@ -0,0 +1,12 @@ +{ + "$ref": "#/definitions/DataEngine", + "definitions": { + "DataEngine": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "description": "Data Engine Interface" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/DataEngineFilter.json b/packages/spec/json-schema/system/DataEngineFilter.json new file mode 100644 index 000000000..afc2c88f4 --- /dev/null +++ b/packages/spec/json-schema/system/DataEngineFilter.json @@ -0,0 +1,11 @@ +{ + "$ref": "#/definitions/DataEngineFilter", + "definitions": { + "DataEngineFilter": { + "type": "object", + "additionalProperties": {}, + "description": "Data Engine query filter conditions" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/DataEngineQueryOptions.json b/packages/spec/json-schema/system/DataEngineQueryOptions.json new file mode 100644 index 000000000..0111673b3 --- /dev/null +++ b/packages/spec/json-schema/system/DataEngineQueryOptions.json @@ -0,0 +1,48 @@ +{ + "$ref": "#/definitions/DataEngineQueryOptions", + "definitions": { + "DataEngineQueryOptions": { + "type": "object", + "properties": { + "filter": { + "type": "object", + "additionalProperties": {}, + "description": "Data Engine query filter conditions" + }, + "select": { + "type": "array", + "items": { + "type": "string" + } + }, + "sort": { + "type": "object", + "additionalProperties": { + "type": [ + "number", + "string" + ], + "enum": [ + 1, + -1, + "asc", + "desc" + ] + } + }, + "limit": { + "type": "number" + }, + "skip": { + "type": "number" + }, + "top": { + "type": "number" + } + }, + "additionalProperties": false, + "description": "Query options for IDataEngine.find() operations" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/src/system/data-engine.zod.ts b/packages/spec/src/system/data-engine.zod.ts new file mode 100644 index 000000000..39c156f28 --- /dev/null +++ b/packages/spec/src/system/data-engine.zod.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +/** + * Data Engine Protocol + * + * Defines the standard interface for data persistence engines. + * This allows different data engines (ObjectQL, Prisma, TypeORM, etc.) + * to be used interchangeably through a common interface. + * + * Following the Dependency Inversion Principle - plugins depend on this interface, + * not on concrete database implementations. + */ + +/** + * Data Engine Query filter conditions + * Simple key-value filter structure for IDataEngine.find() operations + */ +export const DataEngineFilterSchema = z.record(z.any()).describe('Data Engine query filter conditions'); + +/** + * Query options for IDataEngine.find() operations + */ +export const DataEngineQueryOptionsSchema = z.object({ + /** Filter conditions */ + filter: DataEngineFilterSchema.optional(), + /** Fields to select */ + select: z.array(z.string()).optional(), + /** Sort order */ + sort: z.record(z.union([z.literal(1), z.literal(-1), z.literal('asc'), z.literal('desc')])).optional(), + /** Limit number of results (alternative name for top, used by some drivers) */ + limit: z.number().optional(), + /** Skip number of results (for pagination) */ + skip: z.number().optional(), + /** Maximum number of results (OData-style, takes precedence over limit if both specified) */ + top: z.number().optional(), +}).describe('Query options for IDataEngine.find() operations'); + +/** + * Data Engine Interface Schema + * + * Defines the contract for data engine implementations. + */ +export const DataEngineSchema = z.object({ + /** + * Insert a new record + * + * @param objectName - Name of the object/table (e.g., 'user', 'order') + * @param data - Data to insert + * @returns Promise resolving to the created record (including generated ID) + */ + insert: z.function() + .args(z.string(), z.any()) + .returns(z.promise(z.any())) + .describe('Insert a new record'), + + /** + * Find records matching a query + * + * @param objectName - Name of the object/table + * @param query - Query conditions (optional) + * @returns Promise resolving to an array of matching records + */ + find: z.function() + .args(z.string()) + .returns(z.promise(z.array(z.any()))) + .describe('Find records matching a query'), + + /** + * Update a record by ID + * + * @param objectName - Name of the object/table + * @param id - Record ID + * @param data - Updated data (partial update) + * @returns Promise resolving to the updated record + */ + update: z.function() + .args(z.string(), z.any(), z.any()) + .returns(z.promise(z.any())) + .describe('Update a record by ID'), + + /** + * Delete a record by ID + * + * @param objectName - Name of the object/table + * @param id - Record ID + * @returns Promise resolving to true if deleted, false otherwise + */ + delete: z.function() + .args(z.string(), z.any()) + .returns(z.promise(z.boolean())) + .describe('Delete a record by ID'), +}).describe('Data Engine Interface'); + +/** + * TypeScript types derived from schemas + */ +export type DataEngineFilter = z.infer; +export type DataEngineQueryOptions = z.infer; + +// Define the TypeScript interface manually for better type safety +// Zod function schema doesn't handle optional parameters well +export interface IDataEngine { + insert(objectName: string, data: any): Promise; + find(objectName: string, query?: DataEngineQueryOptions): Promise; + update(objectName: string, id: any, data: any): Promise; + delete(objectName: string, id: any): Promise; +} diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index dcb9e3324..f441582fb 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -26,5 +26,9 @@ export * from './datasource.zod'; export * from './driver.zod'; export * from './driver/mongo.zod'; export * from './driver/postgres.zod'; + +// Data Engine Protocol +export * from './data-engine.zod'; + // Note: Auth, Identity, Policy, Role, Organization moved to @objectstack/spec/auth // Note: Territory moved to @objectstack/spec/permission diff --git a/test-dataengine-interface.ts b/test-dataengine-interface.ts new file mode 100644 index 000000000..d0334a86e --- /dev/null +++ b/test-dataengine-interface.ts @@ -0,0 +1,162 @@ +/** + * Test Script for IDataEngine Interface Compliance + * + * This script validates: + * 1. ObjectQL implements IDataEngine interface + * 2. Data engine service registration works + * 3. IDataEngine methods are callable + */ + +import { ObjectKernel } from './packages/runtime/src/mini-kernel.js'; +import { ObjectQLPlugin } from './packages/runtime/src/objectql-plugin.js'; +import { DriverPlugin } from './packages/runtime/src/driver-plugin.js'; +import { ObjectQL } from './packages/objectql/src/index.js'; +import type { IDataEngine } from './packages/spec/src/system/data-engine.zod.js'; + +// Mock driver for testing +class MockDriver { + name = 'mock-driver'; + version = '1.0.0'; + + async connect() { + console.log('[MockDriver] Connected'); + } + + async disconnect() { + console.log('[MockDriver] Disconnected'); + } + + async find(object: string, query: any) { + console.log(`[MockDriver] find(${object})`); + return [{ id: '1', name: 'Test Record' }]; + } + + async findOne(object: string, query: any) { + console.log(`[MockDriver] findOne(${object})`); + return { id: '1', name: 'Test Record' }; + } + + async create(object: string, data: any) { + console.log(`[MockDriver] create(${object})`, data); + return { id: '123', ...data }; + } + + async update(object: string, id: any, data: any) { + console.log(`[MockDriver] update(${object}, ${id})`, data); + return { id, ...data }; + } + + async delete(object: string, id: any) { + console.log(`[MockDriver] delete(${object}, ${id})`); + return true; // Return boolean as per DriverInterface + } +} + +async function testDataEngineService() { + console.log('\n=== Test 1: IDataEngine Service Registration ==='); + + const kernel = new ObjectKernel(); + kernel.use(new ObjectQLPlugin()); + kernel.use(new DriverPlugin(new MockDriver() as any, 'mock')); + + await kernel.bootstrap(); + + // Verify data-engine service is registered + try { + const engine = kernel.getService('data-engine'); + console.log('✅ data-engine service registered'); + console.log('Service type:', engine.constructor.name); + } catch (e: any) { + throw new Error(`FAILED: data-engine service not found: ${e.message}`); + } + + // Verify objectql service is still available (backward compatibility) + try { + const ql = kernel.getService('objectql'); + console.log('✅ objectql service still available (backward compatibility)'); + } catch (e: any) { + throw new Error(`FAILED: objectql service not found: ${e.message}`); + } +} + +async function testDataEngineInterface() { + console.log('\n=== Test 2: IDataEngine Interface Methods ==='); + + const kernel = new ObjectKernel(); + kernel.use(new ObjectQLPlugin()); + kernel.use(new DriverPlugin(new MockDriver() as any, 'mock')); + + await kernel.bootstrap(); + + const engine = kernel.getService('data-engine'); + + // Test insert + console.log('\nTesting insert...'); + const created = await engine.insert('test_object', { name: 'John Doe', email: 'john@example.com' }); + console.log('✅ insert() returned:', created); + + // Test find with QueryOptions + console.log('\nTesting find with QueryOptions...'); + const results = await engine.find('test_object', { + filter: { status: 'active' }, + limit: 10, + sort: { createdAt: -1 } + }); + console.log('✅ find() returned:', results.length, 'records'); + + // Test find without query (all records) + console.log('\nTesting find without query...'); + const allResults = await engine.find('test_object'); + console.log('✅ find() without query returned:', allResults.length, 'records'); + + // Test update + console.log('\nTesting update...'); + const updated = await engine.update('test_object', '123', { name: 'Jane Doe' }); + console.log('✅ update() returned:', updated); + + // Test delete + console.log('\nTesting delete...'); + const deleted = await engine.delete('test_object', '123'); + console.log('✅ delete() returned boolean:', deleted === true || deleted === false); + + if (typeof deleted !== 'boolean') { + throw new Error(`FAILED: delete() should return boolean, got ${typeof deleted}`); + } +} + +async function testBackwardCompatibility() { + console.log('\n=== Test 3: Backward Compatibility ==='); + + const kernel = new ObjectKernel(); + kernel.use(new ObjectQLPlugin()); + kernel.use(new DriverPlugin(new MockDriver() as any, 'mock')); + + await kernel.bootstrap(); + + // Both services should point to the same instance + const engine = kernel.getService('data-engine'); + const ql = kernel.getService('objectql'); + + if (engine !== ql) { + throw new Error('FAILED: data-engine and objectql should be the same instance'); + } + + console.log('✅ data-engine and objectql services are the same instance'); +} + +async function runAllTests() { + console.log('🧪 Starting IDataEngine Interface Tests...\n'); + + try { + await testDataEngineService(); + await testDataEngineInterface(); + await testBackwardCompatibility(); + + console.log('\n✅ All tests passed!\n'); + } catch (error) { + console.error('\n❌ Test failed:', error); + process.exit(1); + } +} + +runAllTests(); diff --git a/test-objectql-plugin.ts b/test-objectql-plugin.ts index 335eab288..2d5dd0c78 100644 --- a/test-objectql-plugin.ts +++ b/test-objectql-plugin.ts @@ -7,7 +7,7 @@ * 3. Multiple plugins with ObjectQL work */ -import { ObjectKernel, ObjectQLPlugin, ObjectQL, SchemaRegistry } from '../packages/runtime/src'; +import { ObjectKernel, ObjectQLPlugin, ObjectQL, SchemaRegistry } from './packages/runtime/src/index.js'; async function testPluginBasedRegistration() { console.log('\n=== Test 1: Plugin-based ObjectQL Registration ==='); @@ -39,7 +39,7 @@ async function testMissingObjectQL() { kernel.getService('objectql'); throw new Error('FAILED: Should have thrown error for missing ObjectQL'); } catch (e: any) { - if (e.message.includes('Service not found')) { + if (e.message.includes('not found')) { console.log('✅ Correctly throws error when ObjectQL service is not registered'); } else { throw e;