@objectql/sdk 3.0.0 → 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/CHANGELOG.md +32 -0
- package/README.md +273 -1
- package/dist/index.d.ts +223 -5
- package/dist/index.js +445 -7
- package/dist/index.js.map +1 -1
- package/jest.config.js +8 -0
- package/package.json +4 -3
- package/src/index.ts +552 -7
- package/test/remote-driver.test.ts +454 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
import { RemoteDriver } from '../src/index';
|
|
2
10
|
|
|
3
11
|
// Mock fetch globally
|
|
@@ -438,4 +446,450 @@ describe('RemoteDriver', () => {
|
|
|
438
446
|
);
|
|
439
447
|
});
|
|
440
448
|
});
|
|
449
|
+
|
|
450
|
+
describe('executeQuery', () => {
|
|
451
|
+
it('should execute a QueryAST and return results', async () => {
|
|
452
|
+
const queryAST = {
|
|
453
|
+
object: 'users',
|
|
454
|
+
fields: ['name', 'email'],
|
|
455
|
+
filters: {
|
|
456
|
+
type: 'comparison' as const,
|
|
457
|
+
field: 'status',
|
|
458
|
+
operator: '=',
|
|
459
|
+
value: 'active'
|
|
460
|
+
},
|
|
461
|
+
sort: [{ field: 'created_at', order: 'desc' as const }],
|
|
462
|
+
top: 10
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const mockResponse = {
|
|
466
|
+
value: [
|
|
467
|
+
{ name: 'Alice', email: 'alice@example.com' },
|
|
468
|
+
{ name: 'Bob', email: 'bob@example.com' }
|
|
469
|
+
],
|
|
470
|
+
count: 2
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
474
|
+
ok: true,
|
|
475
|
+
json: async () => mockResponse
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const result = await driver.executeQuery(queryAST);
|
|
479
|
+
|
|
480
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
481
|
+
'http://localhost:3000/api/query',
|
|
482
|
+
expect.objectContaining({
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers: expect.objectContaining({
|
|
485
|
+
'Content-Type': 'application/json'
|
|
486
|
+
}),
|
|
487
|
+
body: JSON.stringify(queryAST)
|
|
488
|
+
})
|
|
489
|
+
);
|
|
490
|
+
expect(result).toEqual(mockResponse);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('should handle authentication headers in executeQuery', async () => {
|
|
494
|
+
const driverWithAuth = new RemoteDriver({
|
|
495
|
+
baseUrl: 'http://localhost:3000',
|
|
496
|
+
token: 'test-token',
|
|
497
|
+
apiKey: 'test-api-key'
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const queryAST = {
|
|
501
|
+
object: 'users',
|
|
502
|
+
fields: ['name']
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
506
|
+
ok: true,
|
|
507
|
+
json: async () => ({ value: [], count: 0 })
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
await driverWithAuth.executeQuery(queryAST);
|
|
511
|
+
|
|
512
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
513
|
+
'http://localhost:3000/api/query',
|
|
514
|
+
expect.objectContaining({
|
|
515
|
+
headers: expect.objectContaining({
|
|
516
|
+
'Authorization': 'Bearer test-token',
|
|
517
|
+
'X-API-Key': 'test-api-key'
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('should handle different response formats in executeQuery', async () => {
|
|
524
|
+
const queryAST = { object: 'users' };
|
|
525
|
+
|
|
526
|
+
// Test data response format
|
|
527
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
528
|
+
ok: true,
|
|
529
|
+
json: async () => ({ data: [{ id: 1 }] })
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
let result = await driver.executeQuery(queryAST);
|
|
533
|
+
expect(result.value).toEqual([{ id: 1 }]);
|
|
534
|
+
|
|
535
|
+
// Test direct array response
|
|
536
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
537
|
+
ok: true,
|
|
538
|
+
json: async () => [{ id: 2 }]
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
result = await driver.executeQuery(queryAST);
|
|
542
|
+
expect(result.value).toEqual([{ id: 2 }]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('should handle errors in executeQuery', async () => {
|
|
546
|
+
const queryAST = { object: 'users' };
|
|
547
|
+
|
|
548
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
549
|
+
ok: false,
|
|
550
|
+
status: 404,
|
|
551
|
+
statusText: 'Not Found',
|
|
552
|
+
json: async () => ({
|
|
553
|
+
error: {
|
|
554
|
+
code: 'NOT_FOUND',
|
|
555
|
+
message: 'Object not found'
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await expect(driver.executeQuery(queryAST))
|
|
561
|
+
.rejects
|
|
562
|
+
.toThrow('Object not found');
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
describe('executeCommand', () => {
|
|
567
|
+
it('should execute a create command', async () => {
|
|
568
|
+
const command = {
|
|
569
|
+
type: 'create' as const,
|
|
570
|
+
object: 'users',
|
|
571
|
+
data: { name: 'Alice', email: 'alice@example.com' }
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const mockResponse = {
|
|
575
|
+
success: true,
|
|
576
|
+
data: { id: '1', name: 'Alice', email: 'alice@example.com' },
|
|
577
|
+
affected: 1
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
581
|
+
ok: true,
|
|
582
|
+
json: async () => mockResponse
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const result = await driver.executeCommand(command);
|
|
586
|
+
|
|
587
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
588
|
+
'http://localhost:3000/api/command',
|
|
589
|
+
expect.objectContaining({
|
|
590
|
+
method: 'POST',
|
|
591
|
+
headers: expect.objectContaining({
|
|
592
|
+
'Content-Type': 'application/json'
|
|
593
|
+
}),
|
|
594
|
+
body: JSON.stringify(command)
|
|
595
|
+
})
|
|
596
|
+
);
|
|
597
|
+
expect(result).toEqual(mockResponse);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('should execute a bulkUpdate command', async () => {
|
|
601
|
+
const command = {
|
|
602
|
+
type: 'bulkUpdate' as const,
|
|
603
|
+
object: 'users',
|
|
604
|
+
updates: [
|
|
605
|
+
{ id: '1', data: { status: 'active' } },
|
|
606
|
+
{ id: '2', data: { status: 'inactive' } }
|
|
607
|
+
]
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const mockResponse = {
|
|
611
|
+
success: true,
|
|
612
|
+
affected: 2
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
616
|
+
ok: true,
|
|
617
|
+
json: async () => mockResponse
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const result = await driver.executeCommand(command);
|
|
621
|
+
|
|
622
|
+
expect(result.success).toBe(true);
|
|
623
|
+
expect(result.affected).toBe(2);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('should handle command errors', async () => {
|
|
627
|
+
const command = {
|
|
628
|
+
type: 'create' as const,
|
|
629
|
+
object: 'users',
|
|
630
|
+
data: { name: 'Test' }
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
634
|
+
ok: true,
|
|
635
|
+
json: async () => ({
|
|
636
|
+
error: {
|
|
637
|
+
message: 'Validation failed',
|
|
638
|
+
code: 'VALIDATION_ERROR'
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const result = await driver.executeCommand(command);
|
|
644
|
+
|
|
645
|
+
expect(result.success).toBe(false);
|
|
646
|
+
expect(result.error).toContain('Validation failed');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('should handle HTTP errors in executeCommand', async () => {
|
|
650
|
+
const command = {
|
|
651
|
+
type: 'delete' as const,
|
|
652
|
+
object: 'users',
|
|
653
|
+
id: '999'
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
657
|
+
ok: false,
|
|
658
|
+
status: 404,
|
|
659
|
+
statusText: 'Not Found',
|
|
660
|
+
json: async () => ({
|
|
661
|
+
error: {
|
|
662
|
+
code: 'NOT_FOUND',
|
|
663
|
+
message: 'Record not found'
|
|
664
|
+
}
|
|
665
|
+
})
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
await expect(driver.executeCommand(command))
|
|
669
|
+
.rejects
|
|
670
|
+
.toThrow('Record not found');
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe('execute', () => {
|
|
675
|
+
it('should execute custom endpoint', async () => {
|
|
676
|
+
const payload = {
|
|
677
|
+
action: 'calculateMetrics',
|
|
678
|
+
params: { year: 2024 }
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const mockResponse = {
|
|
682
|
+
result: { total: 1000 }
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
686
|
+
ok: true,
|
|
687
|
+
json: async () => mockResponse
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const result = await driver.execute('/api/custom', payload);
|
|
691
|
+
|
|
692
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
693
|
+
'http://localhost:3000/api/custom',
|
|
694
|
+
expect.objectContaining({
|
|
695
|
+
method: 'POST',
|
|
696
|
+
body: JSON.stringify(payload)
|
|
697
|
+
})
|
|
698
|
+
);
|
|
699
|
+
expect(result).toEqual(mockResponse);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('should use default execute endpoint when not specified', async () => {
|
|
703
|
+
const payload = { action: 'test' };
|
|
704
|
+
|
|
705
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
706
|
+
ok: true,
|
|
707
|
+
json: async () => ({ success: true })
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
await driver.execute(undefined, payload);
|
|
711
|
+
|
|
712
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
713
|
+
'http://localhost:3000/api/execute',
|
|
714
|
+
expect.any(Object)
|
|
715
|
+
);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('should handle errors in execute', async () => {
|
|
719
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
720
|
+
ok: false,
|
|
721
|
+
status: 500,
|
|
722
|
+
statusText: 'Internal Server Error',
|
|
723
|
+
json: async () => ({
|
|
724
|
+
error: {
|
|
725
|
+
code: 'INTERNAL_ERROR',
|
|
726
|
+
message: 'Server error'
|
|
727
|
+
}
|
|
728
|
+
})
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
await expect(driver.execute('/api/test', {}))
|
|
732
|
+
.rejects
|
|
733
|
+
.toThrow('Server error');
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
describe('Authentication', () => {
|
|
738
|
+
it('should support token-based authentication', async () => {
|
|
739
|
+
const driverWithToken = new RemoteDriver({
|
|
740
|
+
baseUrl: 'http://localhost:3000',
|
|
741
|
+
token: 'my-secret-token'
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
745
|
+
ok: true,
|
|
746
|
+
json: async () => ({ value: [], count: 0 })
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
await driverWithToken.executeQuery({ object: 'users' });
|
|
750
|
+
|
|
751
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
752
|
+
expect.any(String),
|
|
753
|
+
expect.objectContaining({
|
|
754
|
+
headers: expect.objectContaining({
|
|
755
|
+
'Authorization': 'Bearer my-secret-token'
|
|
756
|
+
})
|
|
757
|
+
})
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('should support API key authentication', async () => {
|
|
762
|
+
const driverWithApiKey = new RemoteDriver({
|
|
763
|
+
baseUrl: 'http://localhost:3000',
|
|
764
|
+
apiKey: 'my-api-key'
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
768
|
+
ok: true,
|
|
769
|
+
json: async () => ({ value: [], count: 0 })
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
await driverWithApiKey.executeQuery({ object: 'users' });
|
|
773
|
+
|
|
774
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
775
|
+
expect.any(String),
|
|
776
|
+
expect.objectContaining({
|
|
777
|
+
headers: expect.objectContaining({
|
|
778
|
+
'X-API-Key': 'my-api-key'
|
|
779
|
+
})
|
|
780
|
+
})
|
|
781
|
+
);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('should support both token and API key', async () => {
|
|
785
|
+
const driverWithBoth = new RemoteDriver({
|
|
786
|
+
baseUrl: 'http://localhost:3000',
|
|
787
|
+
token: 'my-token',
|
|
788
|
+
apiKey: 'my-api-key'
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
792
|
+
ok: true,
|
|
793
|
+
json: async () => ({ value: [], count: 0 })
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
await driverWithBoth.executeQuery({ object: 'users' });
|
|
797
|
+
|
|
798
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
799
|
+
expect.any(String),
|
|
800
|
+
expect.objectContaining({
|
|
801
|
+
headers: expect.objectContaining({
|
|
802
|
+
'Authorization': 'Bearer my-token',
|
|
803
|
+
'X-API-Key': 'my-api-key'
|
|
804
|
+
})
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
describe('Retry Logic', () => {
|
|
811
|
+
beforeEach(() => {
|
|
812
|
+
jest.useFakeTimers();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
afterEach(() => {
|
|
816
|
+
jest.useRealTimers();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it('should retry on network errors when enabled', async () => {
|
|
820
|
+
const driverWithRetry = new RemoteDriver({
|
|
821
|
+
baseUrl: 'http://localhost:3000',
|
|
822
|
+
enableRetry: true,
|
|
823
|
+
maxRetries: 2
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// First two attempts fail, third succeeds
|
|
827
|
+
(global.fetch as jest.Mock)
|
|
828
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
829
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
830
|
+
.mockResolvedValueOnce({
|
|
831
|
+
ok: true,
|
|
832
|
+
json: async () => ({ value: [], count: 0 })
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const promise = driverWithRetry.executeQuery({ object: 'users' });
|
|
836
|
+
|
|
837
|
+
// Fast-forward through retries
|
|
838
|
+
await jest.runAllTimersAsync();
|
|
839
|
+
|
|
840
|
+
const result = await promise;
|
|
841
|
+
|
|
842
|
+
expect(result.value).toEqual([]);
|
|
843
|
+
expect(global.fetch).toHaveBeenCalledTimes(3);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('should not retry on validation errors', async () => {
|
|
847
|
+
const driverWithRetry = new RemoteDriver({
|
|
848
|
+
baseUrl: 'http://localhost:3000',
|
|
849
|
+
enableRetry: true,
|
|
850
|
+
maxRetries: 3
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
854
|
+
ok: false,
|
|
855
|
+
status: 400,
|
|
856
|
+
statusText: 'Bad Request',
|
|
857
|
+
json: async () => ({
|
|
858
|
+
error: {
|
|
859
|
+
code: 'VALIDATION_ERROR',
|
|
860
|
+
message: 'Invalid data'
|
|
861
|
+
}
|
|
862
|
+
})
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
await expect(driverWithRetry.executeQuery({ object: 'users' }))
|
|
866
|
+
.rejects
|
|
867
|
+
.toThrow('Invalid data');
|
|
868
|
+
|
|
869
|
+
// Should only be called once, no retries
|
|
870
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
describe('Config-based Constructor', () => {
|
|
875
|
+
it('should accept config object', () => {
|
|
876
|
+
const driver = new RemoteDriver({
|
|
877
|
+
baseUrl: 'http://localhost:3000',
|
|
878
|
+
queryPath: '/custom/query',
|
|
879
|
+
commandPath: '/custom/command',
|
|
880
|
+
timeout: 60000
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
expect(driver).toBeDefined();
|
|
884
|
+
expect(driver.version).toBe('4.0.0');
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it('should use default paths when not specified', () => {
|
|
888
|
+
const driver = new RemoteDriver({
|
|
889
|
+
baseUrl: 'http://localhost:3000'
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
expect(driver).toBeDefined();
|
|
893
|
+
});
|
|
894
|
+
});
|
|
441
895
|
});
|