quickbase-js 0.1.0-beta.11 → 0.1.0-beta.13
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 +19 -0
- package/README.md +40 -18
- package/dist/esm/quickbase.d.ts +12 -3
- package/dist/esm/quickbase.js +452 -43
- package/dist/esm/quickbase.js.map +1 -1
- package/dist/esm/quickbase.min.js +1 -1
- package/dist/esm/quickbase.min.js.map +1 -1
- package/dist/umd/quickbase.umd.js +452 -43
- package/dist/umd/quickbase.umd.js.map +1 -1
- package/dist/umd/quickbase.umd.min.js +1 -1
- package/dist/umd/quickbase.umd.min.js.map +1 -1
- package/package.json +3 -1
- package/src/BurstAwareThrottleBucket.ts +57 -0
- package/src/FlowThrottleBucket.ts +47 -0
- package/src/RateThrottleBucket.ts +5 -0
- package/src/Semaphore.ts +33 -0
- package/src/authorizationStrategy.ts +12 -1
- package/src/generated-unified/QuickbaseClient.ts +1 -1
- package/src/invokeMethod.ts +102 -17
- package/src/pagination.ts +429 -0
- package/src/quickbaseClient.ts +56 -28
- package/src/rateLimiter.ts +9 -4
- package/tests/vitest/qb/fields/getFieldsUsage.test.ts +2 -2
- package/tests/vitest/qb/quickbase-js/BurstAwareThrottleBucket.test.ts +125 -0
- package/tests/vitest/qb/quickbase-js/FlowBucket.test.ts +77 -0
- package/tests/vitest/qb/quickbase-js/paginationSkip.test.ts +111 -0
- package/tests/vitest/qb/records/runQuery.test.ts +1 -2
- package/tests/vitest/unit/auth/retryUserToken401.test.ts +7 -10
- package/tests/vitest/unit/quickbase-js/BurstAwareThrottleBucket.test.ts +108 -0
- package/tests/vitest/unit/{ThrottleBucket.test.ts → quickbase-js/FlowBucket.test.ts} +16 -10
- package/tests/vitest/unit/quickbase-js/pagination.test.ts +251 -0
- package/tests/vitest/unit/{quickbaseClientHttpMethods.test.ts → quickbase-js/quickbaseClientHttpMethods.test.ts} +13 -11
- package/tests/vitest/unit/records/runQuery.test.ts +3 -4
- package/src/ThrottleBucket.ts +0 -41
- package/tests/vitest/unit/umd.test.ts +0 -8
- /package/tests/vitest/unit/{rateLimit.test.ts → quickbase-js/rateLimit.test.ts} +0 -0
- /package/tests/vitest/unit/{tokenLifespan.test.ts → quickbase-js/tokenLifespan.test.ts} +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { paginateRecords, isPaginatable } from "@/pagination";
|
|
3
|
+
import { QuickbaseClient } from "@/quickbaseClient";
|
|
4
|
+
import * as invokeMethodModule from "@/invokeMethod";
|
|
5
|
+
import { GetUsers200Response, GetUsersRequest } from "@/generated/models";
|
|
6
|
+
|
|
7
|
+
// Extend GetUsersRequest to include nextPageToken
|
|
8
|
+
interface TestGetUsersRequest extends GetUsersRequest {
|
|
9
|
+
nextPageToken?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("Pagination Unit Tests", () => {
|
|
13
|
+
const methodMap = {
|
|
14
|
+
runQuery: {
|
|
15
|
+
api: {},
|
|
16
|
+
method: vi.fn(),
|
|
17
|
+
paramMap: ["body"],
|
|
18
|
+
httpMethod: "POST",
|
|
19
|
+
},
|
|
20
|
+
getUsers: {
|
|
21
|
+
api: {},
|
|
22
|
+
method: vi.fn(),
|
|
23
|
+
paramMap: ["body"],
|
|
24
|
+
httpMethod: "POST",
|
|
25
|
+
},
|
|
26
|
+
} as any;
|
|
27
|
+
const baseHeaders = {};
|
|
28
|
+
const authStrategy = {
|
|
29
|
+
getToken: vi.fn(),
|
|
30
|
+
applyHeaders: vi.fn(),
|
|
31
|
+
handleError: vi.fn(),
|
|
32
|
+
} as any;
|
|
33
|
+
const rateLimiter = {
|
|
34
|
+
throttle: vi.fn(),
|
|
35
|
+
release: vi.fn(),
|
|
36
|
+
maxRetries: 3,
|
|
37
|
+
} as any;
|
|
38
|
+
const transformDates = (obj: any) => obj;
|
|
39
|
+
|
|
40
|
+
it("fetches all pages for runQuery with skip-based pagination", async () => {
|
|
41
|
+
const mockInvoke = vi
|
|
42
|
+
.spyOn(invokeMethodModule, "invokeMethod")
|
|
43
|
+
.mockResolvedValueOnce({
|
|
44
|
+
data: Array(2429).fill({ "3": { value: "record" } }),
|
|
45
|
+
metadata: {
|
|
46
|
+
totalRecords: 5000,
|
|
47
|
+
numRecords: 2429,
|
|
48
|
+
numFields: 6,
|
|
49
|
+
skip: 0,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
.mockResolvedValueOnce({
|
|
53
|
+
data: Array(2429).fill({ "3": { value: "record" } }),
|
|
54
|
+
metadata: {
|
|
55
|
+
totalRecords: 5000,
|
|
56
|
+
numRecords: 2429,
|
|
57
|
+
numFields: 6,
|
|
58
|
+
skip: 2429,
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
.mockResolvedValueOnce({
|
|
62
|
+
data: Array(142).fill({ "3": { value: "record" } }),
|
|
63
|
+
metadata: {
|
|
64
|
+
totalRecords: 5000,
|
|
65
|
+
numRecords: 142,
|
|
66
|
+
numFields: 6,
|
|
67
|
+
skip: 4858,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = await paginateRecords(
|
|
72
|
+
"runQuery",
|
|
73
|
+
{ body: { from: "mock-table", select: [3, 6] } },
|
|
74
|
+
methodMap,
|
|
75
|
+
baseHeaders,
|
|
76
|
+
authStrategy,
|
|
77
|
+
rateLimiter,
|
|
78
|
+
transformDates,
|
|
79
|
+
false,
|
|
80
|
+
true
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(mockInvoke).toHaveBeenCalledTimes(3);
|
|
84
|
+
expect(result.data.length).toBe(5000);
|
|
85
|
+
expect(result.metadata).toEqual({
|
|
86
|
+
totalRecords: 5000,
|
|
87
|
+
numRecords: 5000,
|
|
88
|
+
numFields: 6,
|
|
89
|
+
skip: 0,
|
|
90
|
+
top: undefined,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("fetches all pages for getUsers with token-based pagination", async () => {
|
|
95
|
+
const mockInvoke = vi
|
|
96
|
+
.spyOn(invokeMethodModule, "invokeMethod")
|
|
97
|
+
.mockImplementation(
|
|
98
|
+
async <K extends keyof QuickbaseClient>(
|
|
99
|
+
methodName: K,
|
|
100
|
+
params: Parameters<QuickbaseClient[K]>[0] & {
|
|
101
|
+
skip?: number;
|
|
102
|
+
top?: number;
|
|
103
|
+
},
|
|
104
|
+
methodMap: any,
|
|
105
|
+
baseHeaders: any,
|
|
106
|
+
authStrategy: any,
|
|
107
|
+
rateLimiter: any,
|
|
108
|
+
transformDates: any,
|
|
109
|
+
debug: any,
|
|
110
|
+
convertDates: any,
|
|
111
|
+
autoPaginate: any,
|
|
112
|
+
attempt: any,
|
|
113
|
+
maxAttempts: any,
|
|
114
|
+
isPaginating: any
|
|
115
|
+
): Promise<ReturnType<QuickbaseClient[K]>> => {
|
|
116
|
+
if (methodName !== "getUsers")
|
|
117
|
+
throw new Error("Mock only supports 'getUsers'");
|
|
118
|
+
const body = (params as { body?: TestGetUsersRequest }).body;
|
|
119
|
+
const token = body?.nextPageToken;
|
|
120
|
+
if (!token) {
|
|
121
|
+
return {
|
|
122
|
+
users: Array(10).fill({ id: "user1" }),
|
|
123
|
+
metadata: { nextPageToken: "token1" },
|
|
124
|
+
} as GetUsers200Response as ReturnType<QuickbaseClient[K]>;
|
|
125
|
+
} else if (token === "token1") {
|
|
126
|
+
return {
|
|
127
|
+
users: Array(5).fill({ id: "user2" }),
|
|
128
|
+
metadata: { nextPageToken: "token2" },
|
|
129
|
+
} as GetUsers200Response as ReturnType<QuickbaseClient[K]>;
|
|
130
|
+
} else if (token === "token2") {
|
|
131
|
+
return {
|
|
132
|
+
users: Array(3).fill({ id: "user3" }),
|
|
133
|
+
metadata: { nextPageToken: "" },
|
|
134
|
+
} as GetUsers200Response as ReturnType<QuickbaseClient[K]>;
|
|
135
|
+
}
|
|
136
|
+
throw new Error("Unexpected token");
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const result = await paginateRecords(
|
|
141
|
+
"getUsers",
|
|
142
|
+
{ body: { userIds: ["id1", "id2"] } },
|
|
143
|
+
methodMap,
|
|
144
|
+
baseHeaders,
|
|
145
|
+
authStrategy,
|
|
146
|
+
rateLimiter,
|
|
147
|
+
transformDates,
|
|
148
|
+
false,
|
|
149
|
+
true
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(mockInvoke).toHaveBeenCalledTimes(3);
|
|
153
|
+
expect(mockInvoke).toHaveBeenNthCalledWith(
|
|
154
|
+
1,
|
|
155
|
+
"getUsers",
|
|
156
|
+
expect.objectContaining({ body: { userIds: ["id1", "id2"] } }),
|
|
157
|
+
methodMap,
|
|
158
|
+
baseHeaders,
|
|
159
|
+
authStrategy,
|
|
160
|
+
rateLimiter,
|
|
161
|
+
transformDates,
|
|
162
|
+
false,
|
|
163
|
+
true,
|
|
164
|
+
false,
|
|
165
|
+
0,
|
|
166
|
+
4,
|
|
167
|
+
true
|
|
168
|
+
);
|
|
169
|
+
expect(mockInvoke).toHaveBeenNthCalledWith(
|
|
170
|
+
2,
|
|
171
|
+
"getUsers",
|
|
172
|
+
expect.objectContaining({
|
|
173
|
+
body: { userIds: ["id1", "id2"], nextPageToken: "token1" },
|
|
174
|
+
}),
|
|
175
|
+
methodMap,
|
|
176
|
+
baseHeaders,
|
|
177
|
+
authStrategy,
|
|
178
|
+
rateLimiter,
|
|
179
|
+
transformDates,
|
|
180
|
+
false,
|
|
181
|
+
true,
|
|
182
|
+
false,
|
|
183
|
+
0,
|
|
184
|
+
4,
|
|
185
|
+
true
|
|
186
|
+
);
|
|
187
|
+
expect(mockInvoke).toHaveBeenNthCalledWith(
|
|
188
|
+
3,
|
|
189
|
+
"getUsers",
|
|
190
|
+
expect.objectContaining({
|
|
191
|
+
body: { userIds: ["id1", "id2"], nextPageToken: "token2" },
|
|
192
|
+
}),
|
|
193
|
+
methodMap,
|
|
194
|
+
baseHeaders,
|
|
195
|
+
authStrategy,
|
|
196
|
+
rateLimiter,
|
|
197
|
+
transformDates,
|
|
198
|
+
false,
|
|
199
|
+
true,
|
|
200
|
+
false,
|
|
201
|
+
0,
|
|
202
|
+
4,
|
|
203
|
+
true
|
|
204
|
+
);
|
|
205
|
+
expect(result.users.length).toBe(18);
|
|
206
|
+
expect(result.metadata).toEqual({
|
|
207
|
+
numRecords: 18,
|
|
208
|
+
numFields: undefined,
|
|
209
|
+
nextPageToken: "",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("stops when all skip-based records are fetched in one page", async () => {
|
|
214
|
+
const mockInvoke = vi
|
|
215
|
+
.spyOn(invokeMethodModule, "invokeMethod")
|
|
216
|
+
.mockResolvedValueOnce({
|
|
217
|
+
data: Array(10).fill({ id: "record" }),
|
|
218
|
+
metadata: { totalRecords: 10, numRecords: 10, numFields: 3, skip: 0 },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await paginateRecords(
|
|
222
|
+
"runQuery",
|
|
223
|
+
{ body: { from: "mock-table", select: [3] } },
|
|
224
|
+
methodMap,
|
|
225
|
+
baseHeaders,
|
|
226
|
+
authStrategy,
|
|
227
|
+
rateLimiter,
|
|
228
|
+
transformDates,
|
|
229
|
+
false,
|
|
230
|
+
true
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(result.data.length).toBe(10);
|
|
235
|
+
expect(result.metadata.totalRecords).toBe(10);
|
|
236
|
+
expect(result.metadata.numRecords).toBe(10);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("isPaginatable detects metadata correctly", () => {
|
|
240
|
+
const skipResponse = {
|
|
241
|
+
data: [],
|
|
242
|
+
metadata: { totalRecords: 100, numRecords: 25, numFields: 3, skip: 0 },
|
|
243
|
+
};
|
|
244
|
+
const tokenResponse = { users: [], metadata: { nextPageToken: "abc" } };
|
|
245
|
+
const invalidResponse = { data: [], other: "stuff" };
|
|
246
|
+
|
|
247
|
+
expect(isPaginatable(skipResponse)).toBe(true);
|
|
248
|
+
expect(isPaginatable(tokenResponse)).toBe(true);
|
|
249
|
+
expect(isPaginatable(invalidResponse)).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
// tests/vitest/unit/quickbaseClient.test.ts
|
|
2
|
-
|
|
3
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
4
|
-
import { quickbase } from "
|
|
5
|
-
import { mockFetch, QB_APP_ID, QB_REALM } from "
|
|
2
|
+
import { quickbase } from "@/quickbaseClient";
|
|
3
|
+
import { mockFetch, QB_APP_ID, QB_REALM } from "../../../setup.ts";
|
|
6
4
|
import {
|
|
7
5
|
UpdateAppRequest,
|
|
8
6
|
UpdateApp200Response,
|
|
9
|
-
} from "
|
|
10
|
-
import {
|
|
7
|
+
} from "../../../../src/generated/models/index.ts";
|
|
8
|
+
import { ChangesetSolutionFromRecord200Response } from "../../../../src/generated/models/index.ts";
|
|
11
9
|
|
|
12
10
|
describe("QuickbaseClient Unit - HTTP Method Extraction", () => {
|
|
13
11
|
let clientTemp: ReturnType<typeof quickbase>;
|
|
@@ -58,7 +56,9 @@ describe("QuickbaseClient Unit - HTTP Method Extraction", () => {
|
|
|
58
56
|
dataClassification: "None",
|
|
59
57
|
};
|
|
60
58
|
|
|
61
|
-
const mockChangesetResponse:
|
|
59
|
+
const mockChangesetResponse: ChangesetSolutionFromRecord200Response = [
|
|
60
|
+
{ changes: [] },
|
|
61
|
+
];
|
|
62
62
|
|
|
63
63
|
const setupMockFetch = () => {
|
|
64
64
|
mockFetch.mockReset();
|
|
@@ -109,14 +109,16 @@ describe("QuickbaseClient Unit - HTTP Method Extraction", () => {
|
|
|
109
109
|
expect(apiCall.method).toBe("POST");
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
it("uses
|
|
112
|
+
it("uses GET for changesetSolutionFromRecord", async () => {
|
|
113
113
|
setupMockFetchForChangeset();
|
|
114
|
-
await clientUser.
|
|
114
|
+
await clientUser.changesetSolutionFromRecord({
|
|
115
115
|
solutionId: "solution1",
|
|
116
|
-
|
|
116
|
+
tableId: "table1",
|
|
117
|
+
fieldId: 6,
|
|
118
|
+
recordId: "123",
|
|
117
119
|
});
|
|
118
120
|
const apiCall = mockFetch.mock.calls[0][1];
|
|
119
|
-
expect(apiCall.method).toBe("
|
|
121
|
+
expect(apiCall.method).toBe("GET");
|
|
120
122
|
});
|
|
121
123
|
|
|
122
124
|
it("uses GET for getTable", async () => {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// tests/vitest/unit/records/runQuery.test.ts
|
|
2
1
|
import { describe, test, expect, beforeEach } from "vitest";
|
|
3
2
|
import {
|
|
4
3
|
createClient,
|
|
@@ -7,7 +6,7 @@ import {
|
|
|
7
6
|
QB_USER_TOKEN,
|
|
8
7
|
QB_TABLE_ID_1,
|
|
9
8
|
} from "@tests/setup.ts";
|
|
10
|
-
import { RunQueryRequest,
|
|
9
|
+
import { RunQueryRequest, RunQuery200Response } from "@/generated";
|
|
11
10
|
|
|
12
11
|
describe("QuickbaseClient Unit - runQuery", () => {
|
|
13
12
|
let client: ReturnType<typeof createClient>;
|
|
@@ -27,7 +26,7 @@ describe("QuickbaseClient Unit - runQuery", () => {
|
|
|
27
26
|
options: { skip: 0, top: 100, compareWithAppLocalTime: false },
|
|
28
27
|
};
|
|
29
28
|
|
|
30
|
-
const mockResponse:
|
|
29
|
+
const mockResponse: RunQuery200Response = {
|
|
31
30
|
data: [
|
|
32
31
|
{ "3": { value: 1 }, "6": { value: "Task 1" }, "7": { value: "High" } },
|
|
33
32
|
],
|
|
@@ -40,7 +39,7 @@ describe("QuickbaseClient Unit - runQuery", () => {
|
|
|
40
39
|
numFields: 3,
|
|
41
40
|
numRecords: 1,
|
|
42
41
|
skip: 0,
|
|
43
|
-
top
|
|
42
|
+
// top is omitted or undefined in the final response unless pagination continues
|
|
44
43
|
totalRecords: 1,
|
|
45
44
|
},
|
|
46
45
|
};
|
package/src/ThrottleBucket.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// src/ThrottleBucket.ts
|
|
2
|
-
export class ThrottleBucket {
|
|
3
|
-
private tokens: number;
|
|
4
|
-
private maxTokens: number;
|
|
5
|
-
private refillRate: number; // Tokens per second
|
|
6
|
-
private lastRefill: number;
|
|
7
|
-
private pending: Promise<void> = Promise.resolve(); // Queue for sequential execution
|
|
8
|
-
|
|
9
|
-
constructor(rate: number, burst: number) {
|
|
10
|
-
this.tokens = burst;
|
|
11
|
-
this.maxTokens = burst;
|
|
12
|
-
this.refillRate = rate;
|
|
13
|
-
this.lastRefill = Date.now();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
private refill(): void {
|
|
17
|
-
const now = Date.now();
|
|
18
|
-
const elapsed = (now - this.lastRefill) / 1000; // Seconds elapsed
|
|
19
|
-
const newTokens = elapsed * this.refillRate;
|
|
20
|
-
this.tokens = Math.min(this.maxTokens, this.tokens + newTokens);
|
|
21
|
-
this.lastRefill = now;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async acquire(): Promise<void> {
|
|
25
|
-
// Chain the new acquisition onto the pending queue
|
|
26
|
-
const previous = this.pending;
|
|
27
|
-
this.pending = (async () => {
|
|
28
|
-
await previous; // Wait for prior calls to complete
|
|
29
|
-
this.refill();
|
|
30
|
-
if (this.tokens >= 1) {
|
|
31
|
-
this.tokens -= 1;
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const waitTime = ((1 - this.tokens) / this.refillRate) * 1000; // ms until next token
|
|
35
|
-
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
36
|
-
this.refill();
|
|
37
|
-
this.tokens -= 1;
|
|
38
|
-
})();
|
|
39
|
-
await this.pending;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// tests/vitest/unit/umd.test.ts
|
|
2
|
-
import { test, expect } from "vitest";
|
|
3
|
-
const quickbaseUMD = require("../../dist/umd/quickbase.umd.js");
|
|
4
|
-
const { quickbase: quickbaseESM } = await import("../../dist/esm/quickbase.js");
|
|
5
|
-
test("UMD and ESM builds work in Node.js", () => {
|
|
6
|
-
expect(quickbaseUMD).toBeDefined();
|
|
7
|
-
expect(quickbaseESM).toBeDefined();
|
|
8
|
-
});
|
|
File without changes
|
|
File without changes
|