@objectql/sdk 3.0.1 → 4.0.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/index.ts CHANGED
@@ -1,3 +1,11 @@
1
+ /**
2
+ * ObjectQL
3
+ * Copyright (c) 2026-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
1
9
  /**
2
10
  * @objectql/sdk - Universal HTTP Client for ObjectQL
3
11
  *
@@ -40,8 +48,65 @@ import {
40
48
  MetadataApiResponse,
41
49
  ObjectQLError,
42
50
  ApiErrorCode,
43
- FilterExpression
51
+ Filter
44
52
  } from '@objectql/types';
53
+ import { DriverInterface, QueryAST } from '@objectstack/spec';
54
+
55
+ /**
56
+ * Command interface for executeCommand method
57
+ * Defines the structure of mutation commands sent to the remote API
58
+ */
59
+ export interface Command {
60
+ type: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkUpdate' | 'bulkDelete';
61
+ object: string;
62
+ data?: any;
63
+ id?: string | number;
64
+ ids?: Array<string | number>;
65
+ records?: any[];
66
+ updates?: Array<{id: string | number, data: any}>;
67
+ options?: any;
68
+ }
69
+
70
+ /**
71
+ * Command result interface
72
+ * Standard response format for command execution
73
+ */
74
+ export interface CommandResult {
75
+ success: boolean;
76
+ data?: any;
77
+ affected: number;
78
+ error?: string;
79
+ }
80
+
81
+ /**
82
+ * SDK Configuration for the RemoteDriver
83
+ */
84
+ export interface SdkConfig {
85
+ /** Base URL of the remote ObjectQL server */
86
+ baseUrl: string;
87
+ /** RPC endpoint path (default: /api/objectql) */
88
+ rpcPath?: string;
89
+ /** Query endpoint path (default: /api/query) */
90
+ queryPath?: string;
91
+ /** Command endpoint path (default: /api/command) */
92
+ commandPath?: string;
93
+ /** Custom execute endpoint path (default: /api/execute) */
94
+ executePath?: string;
95
+ /** Authentication token */
96
+ token?: string;
97
+ /** API key for authentication */
98
+ apiKey?: string;
99
+ /** Custom headers */
100
+ headers?: Record<string, string>;
101
+ /** Request timeout in milliseconds (default: 30000) */
102
+ timeout?: number;
103
+ /** Enable retry on failure (default: false) */
104
+ enableRetry?: boolean;
105
+ /** Maximum number of retry attempts (default: 3) */
106
+ maxRetries?: number;
107
+ /** Enable request/response logging (default: false) */
108
+ enableLogging?: boolean;
109
+ }
45
110
 
46
111
  /**
47
112
  * Polyfill for AbortSignal.timeout if not available (for older browsers)
@@ -70,12 +135,457 @@ function createTimeoutSignal(ms: number): AbortSignal {
70
135
 
71
136
  /**
72
137
  * Legacy Driver implementation that uses JSON-RPC style API
138
+ *
139
+ * Implements both the legacy Driver interface from @objectql/types and
140
+ * the standard DriverInterface from @objectstack/spec for compatibility
141
+ * with the new kernel-based plugin system.
142
+ *
143
+ * @version 4.0.0 - DriverInterface compliant
73
144
  */
74
- export class RemoteDriver implements Driver {
145
+ export class RemoteDriver implements Driver, DriverInterface {
146
+ // Driver metadata (ObjectStack-compatible)
147
+ public readonly name = 'RemoteDriver';
148
+ public readonly version = '4.0.0';
149
+ public readonly supports = {
150
+ transactions: false,
151
+ joins: false,
152
+ fullTextSearch: false,
153
+ jsonFields: true,
154
+ arrayFields: true
155
+ };
156
+
75
157
  private rpcPath: string;
158
+ private queryPath: string;
159
+ private commandPath: string;
160
+ private executePath: string;
161
+ private baseUrl: string;
162
+ private token?: string;
163
+ private apiKey?: string;
164
+ private headers: Record<string, string>;
165
+ private timeout: number;
166
+ private enableRetry: boolean;
167
+ private maxRetries: number;
168
+ private enableLogging: boolean;
76
169
 
77
- constructor(private baseUrl: string, rpcPath: string = '/api/objectql') {
78
- this.rpcPath = rpcPath;
170
+ constructor(baseUrlOrConfig: string | SdkConfig, rpcPath?: string) {
171
+ if (typeof baseUrlOrConfig === 'string') {
172
+ // Legacy constructor signature
173
+ this.baseUrl = baseUrlOrConfig;
174
+ this.rpcPath = rpcPath || '/api/objectql';
175
+ this.queryPath = '/api/query';
176
+ this.commandPath = '/api/command';
177
+ this.executePath = '/api/execute';
178
+ this.headers = {};
179
+ this.timeout = 30000;
180
+ this.enableRetry = false;
181
+ this.maxRetries = 3;
182
+ this.enableLogging = false;
183
+ } else {
184
+ // New config-based constructor
185
+ const config = baseUrlOrConfig;
186
+ this.baseUrl = config.baseUrl;
187
+ this.rpcPath = config.rpcPath || '/api/objectql';
188
+ this.queryPath = config.queryPath || '/api/query';
189
+ this.commandPath = config.commandPath || '/api/command';
190
+ this.executePath = config.executePath || '/api/execute';
191
+ this.token = config.token;
192
+ this.apiKey = config.apiKey;
193
+ this.headers = config.headers || {};
194
+ this.timeout = config.timeout || 30000;
195
+ this.enableRetry = config.enableRetry || false;
196
+ this.maxRetries = config.maxRetries || 3;
197
+ this.enableLogging = config.enableLogging || false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Build full endpoint URL
203
+ * @private
204
+ */
205
+ private buildEndpoint(path: string): string {
206
+ return `${this.baseUrl.replace(/\/$/, '')}${path}`;
207
+ }
208
+
209
+ /**
210
+ * Get authentication headers
211
+ * @private
212
+ */
213
+ private getAuthHeaders(): Record<string, string> {
214
+ const authHeaders: Record<string, string> = {};
215
+
216
+ if (this.token) {
217
+ authHeaders['Authorization'] = `Bearer ${this.token}`;
218
+ }
219
+
220
+ if (this.apiKey) {
221
+ authHeaders['X-API-Key'] = this.apiKey;
222
+ }
223
+
224
+ return authHeaders;
225
+ }
226
+
227
+ /**
228
+ * Handle HTTP errors and convert to ObjectQLError
229
+ * @private
230
+ */
231
+ private async handleHttpError(response: Response): Promise<never> {
232
+ let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
233
+ let errorCode: ApiErrorCode = ApiErrorCode.INTERNAL_ERROR;
234
+ let errorDetails: any = undefined;
235
+
236
+ try {
237
+ const json = await response.json();
238
+ if (json.error) {
239
+ errorMessage = json.error.message || errorMessage;
240
+ errorCode = json.error.code || errorCode;
241
+ errorDetails = json.error.details;
242
+ }
243
+ } catch {
244
+ // Could not parse JSON, use default error message
245
+ }
246
+
247
+ // Map HTTP status codes to ObjectQL error codes
248
+ if (response.status === 401) {
249
+ errorCode = ApiErrorCode.UNAUTHORIZED;
250
+ } else if (response.status === 403) {
251
+ errorCode = ApiErrorCode.FORBIDDEN;
252
+ } else if (response.status === 404) {
253
+ errorCode = ApiErrorCode.NOT_FOUND;
254
+ } else if (response.status === 400) {
255
+ errorCode = ApiErrorCode.VALIDATION_ERROR;
256
+ } else if (response.status >= 500) {
257
+ errorCode = ApiErrorCode.INTERNAL_ERROR;
258
+ }
259
+
260
+ throw new ObjectQLError({
261
+ code: errorCode,
262
+ message: errorMessage,
263
+ details: errorDetails
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Retry logic with exponential backoff
269
+ * @private
270
+ */
271
+ private async retryWithBackoff<T>(
272
+ fn: () => Promise<T>,
273
+ attempt: number = 0
274
+ ): Promise<T> {
275
+ let currentAttempt = attempt;
276
+
277
+ while (true) {
278
+ try {
279
+ return await fn();
280
+ } catch (error: any) {
281
+ // Don't retry on client errors (4xx) or if retries are disabled
282
+ if (!this.enableRetry || currentAttempt >= this.maxRetries) {
283
+ throw error;
284
+ }
285
+
286
+ // Don't retry on validation or auth errors
287
+ if (error instanceof ObjectQLError) {
288
+ const nonRetryableCodes = [
289
+ ApiErrorCode.VALIDATION_ERROR,
290
+ ApiErrorCode.UNAUTHORIZED,
291
+ ApiErrorCode.FORBIDDEN,
292
+ ApiErrorCode.NOT_FOUND
293
+ ];
294
+ if (nonRetryableCodes.includes(error.code as ApiErrorCode)) {
295
+ throw error;
296
+ }
297
+ }
298
+
299
+ // Calculate exponential backoff delay
300
+ const delay = Math.min(1000 * Math.pow(2, currentAttempt), 10000);
301
+
302
+ if (this.enableLogging) {
303
+ console.log(`Retry attempt ${currentAttempt + 1}/${this.maxRetries} after ${delay}ms delay`);
304
+ }
305
+
306
+ await new Promise(resolve => setTimeout(resolve, delay));
307
+ currentAttempt++;
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Log request/response if logging is enabled
314
+ * @private
315
+ */
316
+ private log(message: string, data?: any): void {
317
+ if (this.enableLogging) {
318
+ console.log(`[RemoteDriver] ${message}`, data || '');
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Connect to the remote server (for DriverInterface compatibility)
324
+ */
325
+ async connect(): Promise<void> {
326
+ // Test connection with a simple health check
327
+ try {
328
+ await this.checkHealth();
329
+ } catch (error) {
330
+ throw new Error(`Failed to connect to remote server: ${(error as Error).message}`);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Check remote server connection health
336
+ */
337
+ async checkHealth(): Promise<boolean> {
338
+ try {
339
+ const endpoint = `${this.baseUrl.replace(/\/$/, '')}${this.rpcPath}`;
340
+ const res = await fetch(endpoint, {
341
+ method: 'POST',
342
+ headers: {
343
+ 'Content-Type': 'application/json'
344
+ },
345
+ body: JSON.stringify({
346
+ op: 'ping',
347
+ object: '_health',
348
+ args: {}
349
+ })
350
+ });
351
+ return res.ok;
352
+ } catch (error) {
353
+ return false;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Execute a query using QueryAST format (DriverInterface v4.0)
359
+ *
360
+ * Sends a QueryAST to the remote server's /api/query endpoint
361
+ * and returns the query results.
362
+ *
363
+ * @param ast - The QueryAST to execute
364
+ * @param options - Optional execution options
365
+ * @returns Query result with value array and optional count
366
+ *
367
+ * @example
368
+ * ```typescript
369
+ * const result = await driver.executeQuery({
370
+ * object: 'users',
371
+ * fields: ['name', 'email'],
372
+ * filters: {
373
+ * type: 'comparison',
374
+ * field: 'status',
375
+ * operator: '=',
376
+ * value: 'active'
377
+ * },
378
+ * sort: [{ field: 'created_at', order: 'desc' }],
379
+ * top: 10
380
+ * });
381
+ * ```
382
+ */
383
+ async executeQuery(ast: QueryAST, options?: any): Promise<{ value: any[]; count?: number }> {
384
+ return this.retryWithBackoff(async () => {
385
+ const endpoint = this.buildEndpoint(this.queryPath);
386
+ this.log('executeQuery', { endpoint, ast });
387
+
388
+ const headers: Record<string, string> = {
389
+ 'Content-Type': 'application/json',
390
+ ...this.getAuthHeaders(),
391
+ ...this.headers
392
+ };
393
+
394
+ const response = await fetch(endpoint, {
395
+ method: 'POST',
396
+ headers,
397
+ body: JSON.stringify(ast),
398
+ signal: createTimeoutSignal(this.timeout)
399
+ });
400
+
401
+ if (!response.ok) {
402
+ await this.handleHttpError(response);
403
+ }
404
+
405
+ const json = await response.json();
406
+ this.log('executeQuery response', json);
407
+
408
+ // Handle both direct data response and wrapped response formats
409
+ if (json.error) {
410
+ throw new ObjectQLError({
411
+ code: json.error.code || ApiErrorCode.INTERNAL_ERROR,
412
+ message: json.error.message,
413
+ details: json.error.details
414
+ });
415
+ }
416
+
417
+ // Support multiple response formats
418
+ if (json.value !== undefined) {
419
+ return {
420
+ value: Array.isArray(json.value) ? json.value : [json.value],
421
+ count: json.count
422
+ };
423
+ } else if (json.data !== undefined) {
424
+ return {
425
+ value: Array.isArray(json.data) ? json.data : [json.data],
426
+ count: json.count || (Array.isArray(json.data) ? json.data.length : 1)
427
+ };
428
+ } else if (Array.isArray(json)) {
429
+ return {
430
+ value: json,
431
+ count: json.length
432
+ };
433
+ } else {
434
+ return {
435
+ value: [json],
436
+ count: 1
437
+ };
438
+ }
439
+ });
440
+ }
441
+
442
+ /**
443
+ * Execute a command using Command format (DriverInterface v4.0)
444
+ *
445
+ * Sends a Command to the remote server's /api/command endpoint
446
+ * for mutation operations (create, update, delete, bulk operations).
447
+ *
448
+ * @param command - The command to execute
449
+ * @param options - Optional execution options
450
+ * @returns Command execution result
451
+ *
452
+ * @example
453
+ * ```typescript
454
+ * // Create a record
455
+ * const result = await driver.executeCommand({
456
+ * type: 'create',
457
+ * object: 'users',
458
+ * data: { name: 'Alice', email: 'alice@example.com' }
459
+ * });
460
+ *
461
+ * // Bulk update
462
+ * const bulkResult = await driver.executeCommand({
463
+ * type: 'bulkUpdate',
464
+ * object: 'users',
465
+ * updates: [
466
+ * { id: '1', data: { status: 'active' } },
467
+ * { id: '2', data: { status: 'inactive' } }
468
+ * ]
469
+ * });
470
+ * ```
471
+ */
472
+ async executeCommand(command: Command, options?: any): Promise<CommandResult> {
473
+ return this.retryWithBackoff(async () => {
474
+ const endpoint = this.buildEndpoint(this.commandPath);
475
+ this.log('executeCommand', { endpoint, command });
476
+
477
+ const headers: Record<string, string> = {
478
+ 'Content-Type': 'application/json',
479
+ ...this.getAuthHeaders(),
480
+ ...this.headers
481
+ };
482
+
483
+ const response = await fetch(endpoint, {
484
+ method: 'POST',
485
+ headers,
486
+ body: JSON.stringify(command),
487
+ signal: createTimeoutSignal(this.timeout)
488
+ });
489
+
490
+ if (!response.ok) {
491
+ await this.handleHttpError(response);
492
+ }
493
+
494
+ const json = await response.json();
495
+ this.log('executeCommand response', json);
496
+
497
+ // Handle error response
498
+ if (json.error) {
499
+ return {
500
+ success: false,
501
+ error: json.error.message || 'Command execution failed',
502
+ affected: 0
503
+ };
504
+ }
505
+
506
+ // Handle standard CommandResult format
507
+ if (json.success !== undefined) {
508
+ return {
509
+ success: json.success,
510
+ data: json.data,
511
+ affected: json.affected || 0,
512
+ error: json.error
513
+ };
514
+ }
515
+
516
+ // Handle legacy response formats
517
+ return {
518
+ success: true,
519
+ data: json.data || json,
520
+ affected: json.affected || 1
521
+ };
522
+ });
523
+ }
524
+
525
+ /**
526
+ * Execute a custom operation on the remote server
527
+ *
528
+ * Allows calling custom HTTP endpoints with flexible parameters.
529
+ * Useful for custom actions, workflows, or specialized operations.
530
+ *
531
+ * @param endpoint - Optional custom endpoint path (defaults to /api/execute)
532
+ * @param payload - Request payload
533
+ * @param options - Optional execution options
534
+ * @returns Execution result
535
+ *
536
+ * @example
537
+ * ```typescript
538
+ * // Execute a custom workflow
539
+ * const result = await driver.execute('/api/workflows/approve', {
540
+ * workflowId: 'wf_123',
541
+ * comment: 'Approved'
542
+ * });
543
+ *
544
+ * // Use default execute endpoint
545
+ * const result = await driver.execute(undefined, {
546
+ * action: 'calculateMetrics',
547
+ * params: { year: 2024 }
548
+ * });
549
+ * ```
550
+ */
551
+ async execute(endpoint?: string, payload?: any, options?: any): Promise<any> {
552
+ return this.retryWithBackoff(async () => {
553
+ const targetEndpoint = endpoint
554
+ ? this.buildEndpoint(endpoint)
555
+ : this.buildEndpoint(this.executePath);
556
+
557
+ this.log('execute', { endpoint: targetEndpoint, payload });
558
+
559
+ const headers: Record<string, string> = {
560
+ 'Content-Type': 'application/json',
561
+ ...this.getAuthHeaders(),
562
+ ...this.headers
563
+ };
564
+
565
+ const response = await fetch(targetEndpoint, {
566
+ method: 'POST',
567
+ headers,
568
+ body: payload !== undefined ? JSON.stringify(payload) : undefined,
569
+ signal: createTimeoutSignal(this.timeout)
570
+ });
571
+
572
+ if (!response.ok) {
573
+ await this.handleHttpError(response);
574
+ }
575
+
576
+ const json = await response.json();
577
+ this.log('execute response', json);
578
+
579
+ if (json.error) {
580
+ throw new ObjectQLError({
581
+ code: json.error.code || ApiErrorCode.INTERNAL_ERROR,
582
+ message: json.error.message,
583
+ details: json.error.details
584
+ });
585
+ }
586
+
587
+ return json;
588
+ });
79
589
  }
80
590
 
81
591
  private async request(op: string, objectName: string, args: any) {
@@ -104,12 +614,47 @@ export class RemoteDriver implements Driver {
104
614
  return json.data;
105
615
  }
106
616
 
617
+ /**
618
+ * Normalizes query format to support both legacy UnifiedQuery and QueryAST formats.
619
+ * This ensures backward compatibility while supporting the new @objectstack/spec interface.
620
+ *
621
+ * QueryAST format uses 'top' for limit, while UnifiedQuery uses 'limit'.
622
+ * QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
623
+ */
624
+ private normalizeQuery(query: any): any {
625
+ if (!query) return {};
626
+
627
+ const normalized: any = { ...query };
628
+
629
+ // Normalize limit/top
630
+ if (normalized.top !== undefined && normalized.limit === undefined) {
631
+ normalized.limit = normalized.top;
632
+ }
633
+
634
+ // Normalize sort format
635
+ if (normalized.sort && Array.isArray(normalized.sort)) {
636
+ // Check if it's already in the array format [field, order]
637
+ const firstSort = normalized.sort[0];
638
+ if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
639
+ // Convert from QueryAST format {field, order} to internal format [field, order]
640
+ normalized.sort = normalized.sort.map((item: any) => [
641
+ item.field,
642
+ item.order || item.direction || item.dir || 'asc'
643
+ ]);
644
+ }
645
+ }
646
+
647
+ return normalized;
648
+ }
649
+
107
650
  async find(objectName: string, query: any, options?: any): Promise<any[]> {
108
- return this.request('find', objectName, query);
651
+ const normalizedQuery = this.normalizeQuery(query);
652
+ return this.request('find', objectName, normalizedQuery);
109
653
  }
110
654
 
111
655
  async findOne(objectName: string, id: string | number, query?: any, options?: any): Promise<any> {
112
- return this.request('findOne', objectName, { id, query }); // Note: args format must match server expectation
656
+ const normalizedQuery = query ? this.normalizeQuery(query) : undefined;
657
+ return this.request('findOne', objectName, { id, query: normalizedQuery }); // Note: args format must match server expectation
113
658
  }
114
659
 
115
660
  async create(objectName: string, data: any, options?: any): Promise<any> {
@@ -296,7 +841,7 @@ export class DataApiClient implements IDataApiClient {
296
841
  );
297
842
  }
298
843
 
299
- async count(objectName: string, filters?: FilterExpression): Promise<DataApiCountResponse> {
844
+ async count(objectName: string, filters?: Filter): Promise<DataApiCountResponse> {
300
845
  return this.request<DataApiCountResponse>(
301
846
  'GET',
302
847
  `${this.dataPath}/${objectName}`,