@objectql/sdk 3.0.1 → 4.0.1

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