@objectql/server 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IObjectQL, ObjectQLContext } from '@objectql/types';
2
- import { ObjectQLRequest, ObjectQLResponse } from './types';
2
+ import { ObjectQLRequest, ObjectQLResponse, ErrorCode } from './types';
3
3
 
4
4
  export class ObjectQLServer {
5
5
  constructor(private app: IObjectQL) {}
@@ -10,6 +10,17 @@ export class ObjectQLServer {
10
10
  */
11
11
  async handle(req: ObjectQLRequest): Promise<ObjectQLResponse> {
12
12
  try {
13
+ // Log AI context if provided
14
+ if (req.ai_context) {
15
+ console.log('[ObjectQL AI Context]', {
16
+ object: req.object,
17
+ op: req.op,
18
+ intent: req.ai_context.intent,
19
+ natural_language: req.ai_context.natural_language,
20
+ use_case: req.ai_context.use_case
21
+ });
22
+ }
23
+
13
24
  // 1. Build Context
14
25
  // TODO: integrate with real session/auth
15
26
  const contextOptions = {
@@ -23,10 +34,23 @@ export class ObjectQLServer {
23
34
  // We need to cast or fix the interface. Assuming 'app' behaves like ObjectQL class.
24
35
  const app = this.app as any;
25
36
  if (typeof app.createContext !== 'function') {
26
- throw new Error("The provided ObjectQL instance does not support createContext.");
37
+ return this.errorResponse(
38
+ ErrorCode.INTERNAL_ERROR,
39
+ "The provided ObjectQL instance does not support createContext."
40
+ );
27
41
  }
28
42
 
29
43
  const ctx: ObjectQLContext = app.createContext(contextOptions);
44
+
45
+ // Validate object exists
46
+ const objectConfig = app.getObject(req.object);
47
+ if (!objectConfig) {
48
+ return this.errorResponse(
49
+ ErrorCode.NOT_FOUND,
50
+ `Object '${req.object}' not found`
51
+ );
52
+ }
53
+
30
54
  const repo = ctx.object(req.object);
31
55
 
32
56
  let result: any;
@@ -36,7 +60,12 @@ export class ObjectQLServer {
36
60
  result = await repo.find(req.args);
37
61
  break;
38
62
  case 'findOne':
39
- result = await repo.findOne(req.args);
63
+ // Support both string ID and query object
64
+ if (typeof req.args === 'string') {
65
+ result = await repo.findOne({ filters: [['_id', '=', req.args]] });
66
+ } else {
67
+ result = await repo.findOne(req.args);
68
+ }
40
69
  break;
41
70
  case 'create':
42
71
  result = await repo.create(req.args);
@@ -46,33 +75,107 @@ export class ObjectQLServer {
46
75
  break;
47
76
  case 'delete':
48
77
  result = await repo.delete(req.args.id);
78
+ if (!result) {
79
+ return this.errorResponse(
80
+ ErrorCode.NOT_FOUND,
81
+ `Record with id '${req.args.id}' not found for delete`
82
+ );
83
+ }
84
+ // Return standardized delete response on success
85
+ result = { id: req.args.id, deleted: true };
49
86
  break;
50
87
  case 'count':
51
88
  result = await repo.count(req.args);
52
89
  break;
53
90
  case 'action':
54
- // TODO: The repo interface might not expose 'executeAction' directly yet,
55
- // usually it's on the app level or via special method.
56
- // For now, let's assume app.executeAction
91
+ // Map generic args to ActionContext
57
92
  result = await app.executeAction(req.object, req.args.action, {
58
- ...ctx, // Pass context with user info
59
- params: req.args.params
93
+ ...ctx, // Pass context (user, etc.)
94
+ id: req.args.id,
95
+ input: req.args.input || req.args.params // Support both for convenience
60
96
  });
61
97
  break;
62
98
  default:
63
- throw new Error(`Unknown operation: ${req.op}`);
99
+ return this.errorResponse(
100
+ ErrorCode.INVALID_REQUEST,
101
+ `Unknown operation: ${req.op}`
102
+ );
64
103
  }
65
104
 
66
105
  return { data: result };
67
106
 
68
107
  } catch (e: any) {
69
- console.error('[ObjectQL Server] Error:', e);
70
- return {
71
- error: {
72
- code: 'INTERNAL_ERROR',
73
- message: e.message || 'An error occurred'
74
- }
75
- };
108
+ return this.handleError(e);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Handle errors and convert them to appropriate error responses
114
+ */
115
+ private handleError(error: any): ObjectQLResponse {
116
+ console.error('[ObjectQL Server] Error:', error);
117
+
118
+ // Handle validation errors
119
+ if (error.name === 'ValidationError' || error.code === 'VALIDATION_ERROR') {
120
+ return this.errorResponse(
121
+ ErrorCode.VALIDATION_ERROR,
122
+ 'Validation failed',
123
+ { fields: error.fields || error.details }
124
+ );
125
+ }
126
+
127
+ // Handle permission errors
128
+ if (error.name === 'PermissionError' || error.code === 'FORBIDDEN') {
129
+ return this.errorResponse(
130
+ ErrorCode.FORBIDDEN,
131
+ error.message || 'You do not have permission to access this resource',
132
+ error.details
133
+ );
134
+ }
135
+
136
+ // Handle not found errors
137
+ if (error.name === 'NotFoundError' || error.code === 'NOT_FOUND') {
138
+ return this.errorResponse(
139
+ ErrorCode.NOT_FOUND,
140
+ error.message || 'Resource not found'
141
+ );
142
+ }
143
+
144
+ // Handle conflict errors (e.g., unique constraint violations)
145
+ if (error.name === 'ConflictError' || error.code === 'CONFLICT') {
146
+ return this.errorResponse(
147
+ ErrorCode.CONFLICT,
148
+ error.message || 'Resource conflict',
149
+ error.details
150
+ );
151
+ }
152
+
153
+ // Handle database errors
154
+ if (error.name === 'DatabaseError' || error.code?.startsWith('DB_')) {
155
+ return this.errorResponse(
156
+ ErrorCode.DATABASE_ERROR,
157
+ 'Database operation failed',
158
+ { originalError: error.message }
159
+ );
76
160
  }
161
+
162
+ // Default to internal error
163
+ return this.errorResponse(
164
+ ErrorCode.INTERNAL_ERROR,
165
+ error.message || 'An error occurred'
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Create a standardized error response
171
+ */
172
+ private errorResponse(code: ErrorCode, message: string, details?: any): ObjectQLResponse {
173
+ return {
174
+ error: {
175
+ code,
176
+ message,
177
+ details
178
+ }
179
+ };
77
180
  }
78
181
  }
@@ -3,27 +3,40 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
 
5
5
  /**
6
- * Creates a handler to serve the console UI static files.
6
+ * Creates a handler to serve the Studio UI static files.
7
7
  */
8
- export function createConsoleHandler() {
9
- // Try to find the built console files
10
- const possiblePaths = [
11
- path.join(__dirname, '../../console/dist'),
12
- path.join(process.cwd(), 'node_modules/@objectql/console/dist'),
13
- path.join(process.cwd(), 'packages/console/dist'),
14
- ];
15
-
8
+ export function createStudioHandler() {
16
9
  let distPath: string | null = null;
17
- for (const p of possiblePaths) {
18
- if (fs.existsSync(p)) {
19
- distPath = p;
20
- break;
10
+
11
+ // 1. Try to resolve from installed package (Standard way)
12
+ try {
13
+ const studioPkg = require.resolve('@objectql/studio/package.json');
14
+ const candidate = path.join(path.dirname(studioPkg), 'dist');
15
+ if (fs.existsSync(candidate)) {
16
+ distPath = candidate;
17
+ }
18
+ } catch (e) {
19
+ // @objectql/studio might not be installed
20
+ }
21
+
22
+ // 2. Fallback for local development (Monorepo)
23
+ if (!distPath) {
24
+ const possiblePaths = [
25
+ path.join(__dirname, '../../studio/dist'),
26
+ path.join(process.cwd(), 'packages/studio/dist'),
27
+ ];
28
+
29
+ for (const p of possiblePaths) {
30
+ if (fs.existsSync(p)) {
31
+ distPath = p;
32
+ break;
33
+ }
21
34
  }
22
35
  }
23
36
 
24
37
  return async (req: IncomingMessage, res: ServerResponse) => {
25
38
  if (!distPath) {
26
- // Return placeholder page if console is not built
39
+ // Return placeholder page if studio is not built
27
40
  const html = getPlaceholderPage();
28
41
  res.setHeader('Content-Type', 'text/html');
29
42
  res.statusCode = 200;
@@ -31,8 +44,8 @@ export function createConsoleHandler() {
31
44
  return;
32
45
  }
33
46
 
34
- // Parse the URL and remove /console prefix
35
- let urlPath = (req.url || '').replace(/^\/console/, '') || '/';
47
+ // Parse the URL and remove /studio prefix
48
+ let urlPath = (req.url || '').replace(/^\/studio/, '') || '/';
36
49
 
37
50
  // Default to index.html for SPA routing
38
51
  if (urlPath === '/' || !urlPath.includes('.')) {
@@ -97,7 +110,7 @@ function getPlaceholderPage(): string {
97
110
  <head>
98
111
  <meta charset="UTF-8">
99
112
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
100
- <title>ObjectQL Console</title>
113
+ <title>ObjectQL Studio</title>
101
114
  <style>
102
115
  * { margin: 0; padding: 0; box-sizing: border-box; }
103
116
  body {
@@ -133,15 +146,15 @@ function getPlaceholderPage(): string {
133
146
  </head>
134
147
  <body>
135
148
  <div class="container">
136
- <h1>ObjectQL Console</h1>
137
- <p>Web-based admin console for database management</p>
149
+ <h1>ObjectQL Studio</h1>
150
+ <p>Web-based admin studio for database management</p>
138
151
  <div class="info">
139
152
  <p style="margin-bottom: 1rem;">
140
- The console is available but needs to be built separately.
153
+ The studio is available but needs to be built separately.
141
154
  </p>
142
155
  <p style="font-size: 1rem;">
143
- To use the full console UI, run:<br>
144
- <code>cd packages/console && pnpm run build</code>
156
+ To use the full studio UI, run:<br>
157
+ <code>cd packages/studio && pnpm run build</code>
145
158
  </p>
146
159
  </div>
147
160
  </div>
package/src/types.ts CHANGED
@@ -1,4 +1,33 @@
1
1
  // src/types.ts
2
+
3
+ /**
4
+ * Standardized error codes for ObjectQL API
5
+ */
6
+ export enum ErrorCode {
7
+ INVALID_REQUEST = 'INVALID_REQUEST',
8
+ VALIDATION_ERROR = 'VALIDATION_ERROR',
9
+ UNAUTHORIZED = 'UNAUTHORIZED',
10
+ FORBIDDEN = 'FORBIDDEN',
11
+ NOT_FOUND = 'NOT_FOUND',
12
+ CONFLICT = 'CONFLICT',
13
+ INTERNAL_ERROR = 'INTERNAL_ERROR',
14
+ DATABASE_ERROR = 'DATABASE_ERROR',
15
+ RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED'
16
+ }
17
+
18
+ /**
19
+ * AI context for better logging, debugging, and AI processing
20
+ */
21
+ export interface AIContext {
22
+ intent?: string;
23
+ natural_language?: string;
24
+ use_case?: string;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ /**
29
+ * ObjectQL JSON-RPC style request
30
+ */
2
31
  export interface ObjectQLRequest {
3
32
  // Identity provided by the framework adapter (e.g. from session)
4
33
  user?: {
@@ -13,12 +42,37 @@ export interface ObjectQLRequest {
13
42
 
14
43
  // Arguments
15
44
  args: any;
45
+
46
+ // Optional AI context for explainability
47
+ ai_context?: AIContext;
48
+ }
49
+
50
+ /**
51
+ * Error details structure
52
+ */
53
+ export interface ErrorDetails {
54
+ field?: string;
55
+ reason?: string;
56
+ fields?: Record<string, string>;
57
+ required_permission?: string;
58
+ user_roles?: string[];
59
+ retry_after?: number;
60
+ [key: string]: unknown;
16
61
  }
17
62
 
63
+ /**
64
+ * ObjectQL API response
65
+ */
18
66
  export interface ObjectQLResponse {
19
67
  data?: any;
20
68
  error?: {
21
- code: string;
69
+ code: ErrorCode | string;
22
70
  message: string;
23
- }
71
+ details?: ErrorDetails;
72
+ };
73
+ meta?: {
74
+ total?: number;
75
+ page?: number;
76
+ per_page?: number;
77
+ };
24
78
  }
@@ -0,0 +1,164 @@
1
+ import request from 'supertest';
2
+ import { createServer } from 'http';
3
+ import { ObjectQL } from '@objectql/core';
4
+ import { createRESTHandler } from '../src/adapters/rest';
5
+ import { Driver } from '@objectql/types';
6
+
7
+ // Simple Mock Driver
8
+ class MockDriver implements Driver {
9
+ private data: Record<string, any[]> = {
10
+ user: [
11
+ { _id: '1', name: 'Alice', email: 'alice@example.com' },
12
+ { _id: '2', name: 'Bob', email: 'bob@example.com' }
13
+ ]
14
+ };
15
+ private nextId = 3;
16
+
17
+ async init() {}
18
+
19
+ async find(objectName: string, query: any) {
20
+ return this.data[objectName] || [];
21
+ }
22
+
23
+ async findOne(objectName: string, id: string | number, query?: any, options?: any) {
24
+ const items = this.data[objectName] || [];
25
+ if (id !== undefined && id !== null) {
26
+ const found = items.find(item => item._id === String(id));
27
+ return found || null;
28
+ }
29
+ return items[0] || null;
30
+ }
31
+
32
+ async create(objectName: string, data: any) {
33
+ const newItem = { _id: String(this.nextId++), ...data };
34
+ if (!this.data[objectName]) {
35
+ this.data[objectName] = [];
36
+ }
37
+ this.data[objectName].push(newItem);
38
+ return newItem;
39
+ }
40
+
41
+ async update(objectName: string, id: string, data: any) {
42
+ const items = this.data[objectName] || [];
43
+ const index = items.findIndex(item => item._id === id);
44
+ if (index >= 0) {
45
+ this.data[objectName][index] = { ...items[index], ...data };
46
+ return 1;
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ async delete(objectName: string, id: string) {
52
+ const items = this.data[objectName] || [];
53
+ const index = items.findIndex(item => item._id === id);
54
+ if (index >= 0) {
55
+ this.data[objectName].splice(index, 1);
56
+ return 1;
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ async count(objectName: string, query: any) {
62
+ return (this.data[objectName] || []).length;
63
+ }
64
+
65
+ async execute(sql: string) {}
66
+ }
67
+
68
+ describe('REST API Adapter', () => {
69
+ let app: ObjectQL;
70
+ let server: any;
71
+ let handler: any;
72
+
73
+ beforeAll(async () => {
74
+ app = new ObjectQL({
75
+ datasources: {
76
+ default: new MockDriver()
77
+ }
78
+ });
79
+
80
+ // Manual schema registration
81
+ app.metadata.register('object', {
82
+ type: 'object',
83
+ id: 'user',
84
+ content: {
85
+ name: 'user',
86
+ fields: {
87
+ name: { type: 'text' },
88
+ email: { type: 'email' }
89
+ }
90
+ }
91
+ });
92
+
93
+ // Create handler and server once for all tests
94
+ handler = createRESTHandler(app);
95
+ server = createServer(handler);
96
+ });
97
+
98
+ it('should handle GET /api/data/:object - List records', async () => {
99
+ const response = await request(server)
100
+ .get('/api/data/user')
101
+ .set('Accept', 'application/json');
102
+
103
+ expect(response.status).toBe(200);
104
+ expect(response.body.data).toHaveLength(2);
105
+ expect(response.body.data[0].name).toBe('Alice');
106
+ });
107
+
108
+ it('should handle GET /api/data/:object/:id - Get single record', async () => {
109
+ const response = await request(server)
110
+ .get('/api/data/user/1')
111
+ .set('Accept', 'application/json');
112
+
113
+ expect(response.status).toBe(200);
114
+ expect(response.body.data.name).toBe('Alice');
115
+ });
116
+
117
+ it('should handle POST /api/data/:object - Create record', async () => {
118
+ const response = await request(server)
119
+ .post('/api/data/user')
120
+ .send({ name: 'Charlie', email: 'charlie@example.com' })
121
+ .set('Accept', 'application/json');
122
+
123
+ expect(response.status).toBe(201);
124
+ expect(response.body.data.name).toBe('Charlie');
125
+ expect(response.body.data._id).toBeDefined();
126
+ });
127
+
128
+ it('should handle PUT /api/data/:object/:id - Update record', async () => {
129
+ const response = await request(server)
130
+ .put('/api/data/user/1')
131
+ .send({ name: 'Alice Updated' })
132
+ .set('Accept', 'application/json');
133
+
134
+ expect(response.status).toBe(200);
135
+ });
136
+
137
+ it('should handle DELETE /api/data/:object/:id - Delete record', async () => {
138
+ const response = await request(server)
139
+ .delete('/api/data/user/1')
140
+ .set('Accept', 'application/json');
141
+
142
+ expect(response.status).toBe(200);
143
+ expect(response.body.data.deleted).toBe(true);
144
+ });
145
+
146
+ it('should return 404 for non-existent object', async () => {
147
+ const response = await request(server)
148
+ .get('/api/data/nonexistent')
149
+ .set('Accept', 'application/json');
150
+
151
+ expect(response.status).toBe(404);
152
+ expect(response.body.error.code).toBe('NOT_FOUND');
153
+ });
154
+
155
+ it('should return 400 for update without ID', async () => {
156
+ const response = await request(server)
157
+ .put('/api/data/user')
158
+ .send({ name: 'Test' })
159
+ .set('Accept', 'application/json');
160
+
161
+ expect(response.status).toBe(400);
162
+ expect(response.body.error.code).toBe('INVALID_REQUEST');
163
+ });
164
+ });