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,429 @@
|
|
|
1
|
+
import { QuickbaseClient } from "./quickbaseClient";
|
|
2
|
+
import { invokeMethod, MethodInfo } from "./invokeMethod";
|
|
3
|
+
import { AuthorizationStrategy } from "./authorizationStrategy";
|
|
4
|
+
import { RateLimiter } from "./rateLimiter";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Represents a paginated response from the Quick Base API.
|
|
8
|
+
* @template T - The type of items in the data array.
|
|
9
|
+
*/
|
|
10
|
+
interface PaginatedResponse<T> {
|
|
11
|
+
/** The key containing the array of records (e.g., "data" or "users") and additional metadata. */
|
|
12
|
+
[key: string]: T[] | any;
|
|
13
|
+
/** Optional array of field definitions for the records. */
|
|
14
|
+
fields?: { id: number; label: string; type: string }[];
|
|
15
|
+
/** Metadata describing the pagination state. */
|
|
16
|
+
metadata: {
|
|
17
|
+
/** Total number of records available (skip-based pagination). */
|
|
18
|
+
totalRecords?: number;
|
|
19
|
+
/** Number of records in the current page. */
|
|
20
|
+
numRecords?: number;
|
|
21
|
+
/** Number of fields in the records. */
|
|
22
|
+
numFields?: number;
|
|
23
|
+
/** Number of records skipped in the current page (skip-based pagination). */
|
|
24
|
+
skip?: number;
|
|
25
|
+
/** Maximum number of records per page (skip-based pagination). */
|
|
26
|
+
top?: number;
|
|
27
|
+
/** Token for the next page (token-based pagination). */
|
|
28
|
+
nextPageToken?: string;
|
|
29
|
+
/** Alternative token for the next page (token-based pagination). */
|
|
30
|
+
nextToken?: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Represents a request body that may include pagination options.
|
|
36
|
+
*/
|
|
37
|
+
interface BodyWithOptions {
|
|
38
|
+
/** Optional request body containing pagination parameters. */
|
|
39
|
+
body?: {
|
|
40
|
+
/** Optional pagination options for skip-based requests. */
|
|
41
|
+
options?: {
|
|
42
|
+
/** Sorting criteria for the records. */
|
|
43
|
+
sortBy?: Array<{ fieldId: number; order: "ASC" | "DESC" }>;
|
|
44
|
+
/** Number of records to skip. */
|
|
45
|
+
skip?: number;
|
|
46
|
+
/** Maximum number of records to return per page. */
|
|
47
|
+
top?: number;
|
|
48
|
+
/** Token for the next page (token-based pagination). */
|
|
49
|
+
nextPageToken?: string;
|
|
50
|
+
/** Alternative token for the next page (token-based pagination). */
|
|
51
|
+
nextToken?: string;
|
|
52
|
+
/** Additional arbitrary options. */
|
|
53
|
+
[key: string]: any;
|
|
54
|
+
};
|
|
55
|
+
/** Token for the next page (token-based pagination, directly in body). */
|
|
56
|
+
nextPageToken?: string;
|
|
57
|
+
/** Alternative token for the next page (token-based pagination, directly in body). */
|
|
58
|
+
nextToken?: string;
|
|
59
|
+
/** Additional arbitrary body properties. */
|
|
60
|
+
[key: string]: any;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Defines the continuation state for pagination, either skip-based or token-based.
|
|
66
|
+
*/
|
|
67
|
+
type PaginationContinuation =
|
|
68
|
+
| { type: "skip"; value: number } // Skip-based: number of records to skip next
|
|
69
|
+
| { type: "token"; value: string; key: "nextPageToken" | "nextToken" }; // Token-based: token for the next page
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Paginates records from a Quick Base API endpoint, handling both skip-based and token-based pagination.
|
|
73
|
+
* @template K - The method name keyof QuickbaseClient.
|
|
74
|
+
* @param methodName - The Quick Base API method to invoke (e.g., "runQuery", "getUsers").
|
|
75
|
+
* @param params - Parameters for the API method, including optional skip and top values.
|
|
76
|
+
* @param methodMap - Mapping of method names to their implementation details.
|
|
77
|
+
* @param baseHeaders - Base HTTP headers for API requests.
|
|
78
|
+
* @param authStrategy - Strategy for handling authentication tokens.
|
|
79
|
+
* @param rateLimiter - Rate limiting mechanism to respect API limits.
|
|
80
|
+
* @param transformDates - Function to transform date strings in the response.
|
|
81
|
+
* @param debug - If true, logs detailed pagination information to the console.
|
|
82
|
+
* @param convertDates - If true, converts date strings to Date objects in the response.
|
|
83
|
+
* @param initialResponse - Optional initial response to start pagination from (used by invokeMethod).
|
|
84
|
+
* @returns A promise resolving to the complete paginated response with all records.
|
|
85
|
+
* @throws {Error} If no response is received during pagination.
|
|
86
|
+
*/
|
|
87
|
+
export async function paginateRecords<K extends keyof QuickbaseClient>(
|
|
88
|
+
methodName: K,
|
|
89
|
+
params: Parameters<QuickbaseClient[K]>[0] & { skip?: number; top?: number },
|
|
90
|
+
methodMap: { [P in keyof QuickbaseClient]: MethodInfo<P> },
|
|
91
|
+
baseHeaders: Record<string, string>,
|
|
92
|
+
authStrategy: AuthorizationStrategy,
|
|
93
|
+
rateLimiter: RateLimiter,
|
|
94
|
+
transformDates: (obj: any, convertStringsToDates: boolean) => any,
|
|
95
|
+
debug: boolean | undefined,
|
|
96
|
+
convertDates: boolean,
|
|
97
|
+
initialResponse?: PaginatedResponse<any>
|
|
98
|
+
): Promise<ReturnType<QuickbaseClient[K]>> {
|
|
99
|
+
if (debug) console.log("[paginateRecords] Starting with params:", params);
|
|
100
|
+
|
|
101
|
+
/** Extracts the key containing the data array from the response (e.g., "data" or "users"). */
|
|
102
|
+
const getDataKey = (response: PaginatedResponse<any>): string =>
|
|
103
|
+
Object.keys(response).find((key) => Array.isArray(response[key])) || "data";
|
|
104
|
+
|
|
105
|
+
const dataKey = initialResponse ? getDataKey(initialResponse) : "data";
|
|
106
|
+
let allRecords: any[] = initialResponse ? initialResponse[dataKey] : [];
|
|
107
|
+
let lastResponse: PaginatedResponse<any> | undefined = initialResponse;
|
|
108
|
+
let requestCount = 0;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Determines the pagination strategy based on response metadata.
|
|
112
|
+
* @param response - The API response to analyze.
|
|
113
|
+
* @returns "skip" for skip-based, "token" for token-based, or "none" if no pagination is detected.
|
|
114
|
+
*/
|
|
115
|
+
const determinePaginationType = (
|
|
116
|
+
response: PaginatedResponse<any>
|
|
117
|
+
): "skip" | "token" | "none" => {
|
|
118
|
+
if (
|
|
119
|
+
response.metadata.totalRecords !== undefined &&
|
|
120
|
+
response.metadata.skip !== undefined
|
|
121
|
+
) {
|
|
122
|
+
if (debug)
|
|
123
|
+
console.log("[paginateRecords] Detected skip-based pagination");
|
|
124
|
+
return "skip";
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
response.metadata.nextPageToken !== undefined ||
|
|
128
|
+
response.metadata.nextToken !== undefined
|
|
129
|
+
) {
|
|
130
|
+
if (debug)
|
|
131
|
+
console.log("[paginateRecords] Detected token-based pagination");
|
|
132
|
+
return "token";
|
|
133
|
+
}
|
|
134
|
+
if (debug)
|
|
135
|
+
console.log(
|
|
136
|
+
"[paginateRecords] No clear pagination type detected, assuming single-page response"
|
|
137
|
+
);
|
|
138
|
+
return "none"; // No pagination indicators
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let paginationType: "skip" | "token" | "none" = initialResponse
|
|
142
|
+
? determinePaginationType(initialResponse)
|
|
143
|
+
: "none"; // Start with no pagination assumption
|
|
144
|
+
|
|
145
|
+
let continuation: PaginationContinuation | null = initialResponse
|
|
146
|
+
? paginationType === "skip"
|
|
147
|
+
? {
|
|
148
|
+
type: "skip",
|
|
149
|
+
value:
|
|
150
|
+
(initialResponse.metadata.skip ?? 0) +
|
|
151
|
+
(initialResponse.metadata.numRecords || allRecords.length),
|
|
152
|
+
}
|
|
153
|
+
: paginationType === "token"
|
|
154
|
+
? {
|
|
155
|
+
type: "token",
|
|
156
|
+
value:
|
|
157
|
+
initialResponse.metadata.nextPageToken ||
|
|
158
|
+
initialResponse.metadata.nextToken ||
|
|
159
|
+
"",
|
|
160
|
+
key:
|
|
161
|
+
initialResponse.metadata.nextPageToken !== undefined
|
|
162
|
+
? "nextPageToken"
|
|
163
|
+
: "nextToken",
|
|
164
|
+
}
|
|
165
|
+
: null
|
|
166
|
+
: null; // No continuation until type is determined
|
|
167
|
+
|
|
168
|
+
if (debug) {
|
|
169
|
+
console.log("[paginateRecords] Initial state:", {
|
|
170
|
+
dataKey,
|
|
171
|
+
paginationType,
|
|
172
|
+
continuation,
|
|
173
|
+
hasInitialResponse: !!initialResponse,
|
|
174
|
+
totalRecordsSoFar: allRecords.length,
|
|
175
|
+
});
|
|
176
|
+
if (initialResponse && initialResponse[dataKey].length > 0) {
|
|
177
|
+
console.log(
|
|
178
|
+
"[paginateRecords] Initial response IDs: first:",
|
|
179
|
+
initialResponse[dataKey][0]["3"]?.value,
|
|
180
|
+
"last:",
|
|
181
|
+
initialResponse[dataKey][initialResponse[dataKey].length - 1]["3"]
|
|
182
|
+
?.value
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Fetches the next page of records using the provided parameters. */
|
|
188
|
+
const fetchNextPage = async (
|
|
189
|
+
paginatedParams: any
|
|
190
|
+
): Promise<PaginatedResponse<any>> => {
|
|
191
|
+
if (debug)
|
|
192
|
+
console.log("[paginateRecords] Fetching with params:", paginatedParams);
|
|
193
|
+
const response = await invokeMethod(
|
|
194
|
+
methodName,
|
|
195
|
+
paginatedParams,
|
|
196
|
+
methodMap,
|
|
197
|
+
baseHeaders,
|
|
198
|
+
authStrategy,
|
|
199
|
+
rateLimiter,
|
|
200
|
+
transformDates,
|
|
201
|
+
debug,
|
|
202
|
+
convertDates,
|
|
203
|
+
false,
|
|
204
|
+
0,
|
|
205
|
+
rateLimiter.maxRetries + 1,
|
|
206
|
+
true
|
|
207
|
+
);
|
|
208
|
+
requestCount++;
|
|
209
|
+
return response as PaginatedResponse<any>;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** Constructs the parameters for the next API request based on the pagination type. */
|
|
213
|
+
const getPaginatedParams = (): any => {
|
|
214
|
+
const hasBody = "body" in params && params.body !== undefined;
|
|
215
|
+
if (paginationType === "skip" && continuation?.type === "skip") {
|
|
216
|
+
const paginatedOptions = {
|
|
217
|
+
...(hasBody && "options" in params.body! ? params.body!.options : {}),
|
|
218
|
+
skip: continuation.value,
|
|
219
|
+
};
|
|
220
|
+
return hasBody
|
|
221
|
+
? {
|
|
222
|
+
...params,
|
|
223
|
+
body: {
|
|
224
|
+
...(params as BodyWithOptions).body!,
|
|
225
|
+
options: paginatedOptions,
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
: { ...params, skip: continuation.value };
|
|
229
|
+
} else if (paginationType === "token" && continuation?.type === "token") {
|
|
230
|
+
if (continuation.value === "" && !lastResponse) {
|
|
231
|
+
return { ...params }; // Initial call, no pagination fields
|
|
232
|
+
}
|
|
233
|
+
const tokenKey = continuation.key;
|
|
234
|
+
return hasBody
|
|
235
|
+
? {
|
|
236
|
+
...params,
|
|
237
|
+
body: {
|
|
238
|
+
...(params as BodyWithOptions).body!,
|
|
239
|
+
[tokenKey]: continuation.value,
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
: { ...params, [tokenKey]: continuation.value };
|
|
243
|
+
}
|
|
244
|
+
return params; // No pagination or initial call
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (!initialResponse) {
|
|
248
|
+
const firstParams = getPaginatedParams();
|
|
249
|
+
const firstResponse = await fetchNextPage(firstParams);
|
|
250
|
+
const actualDataKey = getDataKey(firstResponse);
|
|
251
|
+
allRecords = firstResponse[actualDataKey];
|
|
252
|
+
lastResponse = firstResponse;
|
|
253
|
+
paginationType = determinePaginationType(firstResponse);
|
|
254
|
+
continuation =
|
|
255
|
+
paginationType === "skip"
|
|
256
|
+
? {
|
|
257
|
+
type: "skip",
|
|
258
|
+
value:
|
|
259
|
+
(firstResponse.metadata.skip ?? 0) +
|
|
260
|
+
(firstResponse.metadata.numRecords || allRecords.length),
|
|
261
|
+
}
|
|
262
|
+
: paginationType === "token"
|
|
263
|
+
? {
|
|
264
|
+
type: "token",
|
|
265
|
+
value:
|
|
266
|
+
firstResponse.metadata.nextPageToken ??
|
|
267
|
+
firstResponse.metadata.nextToken ??
|
|
268
|
+
"",
|
|
269
|
+
key:
|
|
270
|
+
firstResponse.metadata.nextPageToken !== undefined
|
|
271
|
+
? "nextPageToken"
|
|
272
|
+
: "nextToken",
|
|
273
|
+
}
|
|
274
|
+
: null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
while (true) {
|
|
278
|
+
const hasMore =
|
|
279
|
+
(paginationType === "skip" &&
|
|
280
|
+
continuation?.type === "skip" &&
|
|
281
|
+
lastResponse!.metadata.totalRecords !== undefined &&
|
|
282
|
+
continuation.value < lastResponse!.metadata.totalRecords &&
|
|
283
|
+
allRecords.length < lastResponse!.metadata.totalRecords) ||
|
|
284
|
+
(paginationType === "token" &&
|
|
285
|
+
continuation?.type === "token" &&
|
|
286
|
+
continuation.value !== "");
|
|
287
|
+
|
|
288
|
+
if (!hasMore) {
|
|
289
|
+
if (debug) {
|
|
290
|
+
console.log(
|
|
291
|
+
"[paginateRecords] Stopping:",
|
|
292
|
+
paginationType === "skip"
|
|
293
|
+
? "All skip-based records fetched or no more data"
|
|
294
|
+
: paginationType === "token"
|
|
295
|
+
? "Token exhausted"
|
|
296
|
+
: "No pagination required"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const paginatedParams = getPaginatedParams();
|
|
303
|
+
const response = await fetchNextPage(paginatedParams);
|
|
304
|
+
const actualDataKey = getDataKey(response);
|
|
305
|
+
allRecords = allRecords.concat(response[actualDataKey]);
|
|
306
|
+
lastResponse = response;
|
|
307
|
+
|
|
308
|
+
if (debug && response[actualDataKey].length > 0) {
|
|
309
|
+
console.log(
|
|
310
|
+
"[paginateRecords] Fetched records this iteration:",
|
|
311
|
+
response[actualDataKey].length,
|
|
312
|
+
"IDs: first:",
|
|
313
|
+
response[actualDataKey][0]["3"]?.value,
|
|
314
|
+
"last:",
|
|
315
|
+
response[actualDataKey][response[actualDataKey].length - 1]["3"]?.value,
|
|
316
|
+
"Continuation after fetch:",
|
|
317
|
+
continuation
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (paginationType === "skip") {
|
|
322
|
+
const newSkip =
|
|
323
|
+
continuation?.type === "skip"
|
|
324
|
+
? continuation.value + response[actualDataKey].length
|
|
325
|
+
: (response.metadata.skip ?? 0) + response[actualDataKey].length;
|
|
326
|
+
continuation = { type: "skip", value: newSkip };
|
|
327
|
+
if (response[actualDataKey].length === 0) {
|
|
328
|
+
if (
|
|
329
|
+
debug &&
|
|
330
|
+
lastResponse!.metadata.totalRecords &&
|
|
331
|
+
newSkip < lastResponse!.metadata.totalRecords
|
|
332
|
+
) {
|
|
333
|
+
console.warn(
|
|
334
|
+
"[paginateRecords] Warning: Empty response received but records remain",
|
|
335
|
+
{ skip: newSkip, totalRecords: lastResponse!.metadata.totalRecords }
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
} else if (paginationType === "token") {
|
|
341
|
+
const newToken =
|
|
342
|
+
response.metadata.nextPageToken ?? response.metadata.nextToken ?? "";
|
|
343
|
+
continuation = {
|
|
344
|
+
type: "token",
|
|
345
|
+
value: newToken,
|
|
346
|
+
key:
|
|
347
|
+
response.metadata.nextPageToken !== undefined
|
|
348
|
+
? "nextPageToken"
|
|
349
|
+
: "nextToken",
|
|
350
|
+
};
|
|
351
|
+
if (continuation.value === "") {
|
|
352
|
+
break;
|
|
353
|
+
} else if (response[actualDataKey].length === 0 && newToken !== "") {
|
|
354
|
+
if (debug)
|
|
355
|
+
console.warn(
|
|
356
|
+
"[paginateRecords] Warning: Empty response with non-empty token, possible API inconsistency",
|
|
357
|
+
{ token: newToken }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!lastResponse) {
|
|
364
|
+
throw new Error("[paginateRecords] No response received during pagination");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const finalDataKey = getDataKey(lastResponse);
|
|
368
|
+
const finalResponse: PaginatedResponse<any> = {
|
|
369
|
+
[finalDataKey]: allRecords,
|
|
370
|
+
fields: lastResponse.fields,
|
|
371
|
+
metadata: {
|
|
372
|
+
totalRecords:
|
|
373
|
+
paginationType === "skip"
|
|
374
|
+
? lastResponse.metadata.totalRecords
|
|
375
|
+
: undefined,
|
|
376
|
+
numRecords: allRecords.length,
|
|
377
|
+
numFields: lastResponse.metadata.numFields,
|
|
378
|
+
skip: paginationType === "skip" ? 0 : undefined,
|
|
379
|
+
top: paginationType === "skip" ? undefined : undefined,
|
|
380
|
+
...(paginationType === "token" &&
|
|
381
|
+
continuation?.type === "token" &&
|
|
382
|
+
continuation.key
|
|
383
|
+
? { [continuation.key]: "" }
|
|
384
|
+
: {}),
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
if (debug) {
|
|
389
|
+
console.log("[paginateRecords] Final response summary:", {
|
|
390
|
+
totalRecords: finalResponse.metadata.totalRecords,
|
|
391
|
+
numRecords: finalResponse.metadata.numRecords,
|
|
392
|
+
numFields: finalResponse.metadata.numFields,
|
|
393
|
+
skip: finalResponse.metadata.skip,
|
|
394
|
+
top: finalResponse.metadata.top,
|
|
395
|
+
...(paginationType === "token" &&
|
|
396
|
+
continuation?.type === "token" &&
|
|
397
|
+
continuation.key
|
|
398
|
+
? { [continuation.key]: finalResponse.metadata[continuation.key] }
|
|
399
|
+
: {}),
|
|
400
|
+
});
|
|
401
|
+
console.log("[paginateRecords] Pagination stats:", {
|
|
402
|
+
totalRequests: requestCount,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return finalResponse as ReturnType<QuickbaseClient[K]>;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Checks if a response is paginatable based on its structure.
|
|
411
|
+
* @param response - The response to check.
|
|
412
|
+
* @returns True if the response has a data array and pagination metadata, false otherwise.
|
|
413
|
+
*/
|
|
414
|
+
export function isPaginatable(
|
|
415
|
+
response: any
|
|
416
|
+
): response is PaginatedResponse<any> {
|
|
417
|
+
return (
|
|
418
|
+
response &&
|
|
419
|
+
typeof response === "object" &&
|
|
420
|
+
Object.keys(response).some((key) => Array.isArray(response[key])) &&
|
|
421
|
+
"metadata" in response &&
|
|
422
|
+
typeof response.metadata === "object" &&
|
|
423
|
+
(("totalRecords" in response.metadata &&
|
|
424
|
+
"numRecords" in response.metadata &&
|
|
425
|
+
"skip" in response.metadata) ||
|
|
426
|
+
"nextPageToken" in response.metadata ||
|
|
427
|
+
"nextToken" in response.metadata)
|
|
428
|
+
);
|
|
429
|
+
}
|
package/src/quickbaseClient.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/quickbaseClient.ts
|
|
2
1
|
import { QuickbaseClient as IQuickbaseClient } from "./generated-unified/QuickbaseClient";
|
|
3
2
|
import { Configuration, HTTPHeaders } from "./generated/runtime";
|
|
4
3
|
import * as apis from "./generated/apis";
|
|
@@ -16,7 +15,8 @@ import {
|
|
|
16
15
|
AuthorizationStrategy,
|
|
17
16
|
SsoTokenStrategy,
|
|
18
17
|
} from "./authorizationStrategy";
|
|
19
|
-
import {
|
|
18
|
+
import { FlowThrottleBucket } from "./FlowThrottleBucket";
|
|
19
|
+
import { BurstAwareThrottleBucket } from "./BurstAwareThrottleBucket";
|
|
20
20
|
import { RateLimiter } from "./rateLimiter";
|
|
21
21
|
|
|
22
22
|
export * from "./generated/models/index";
|
|
@@ -34,13 +34,26 @@ export interface QuickbaseConfig {
|
|
|
34
34
|
fetchApi?: typeof fetch;
|
|
35
35
|
convertDates?: boolean;
|
|
36
36
|
tempTokenLifespan?: number;
|
|
37
|
-
throttle?: {
|
|
37
|
+
throttle?: {
|
|
38
|
+
type?: "flow" | "burst-aware";
|
|
39
|
+
rate?: number;
|
|
40
|
+
burst?: number;
|
|
41
|
+
windowSeconds?: number;
|
|
42
|
+
};
|
|
38
43
|
maxRetries?: number;
|
|
39
44
|
retryDelay?: number;
|
|
40
45
|
tokenCache?: TokenCache;
|
|
41
46
|
baseUrl?: string;
|
|
47
|
+
autoPaginate?: boolean;
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
export type ThrottleOptions = {
|
|
51
|
+
type?: "flow" | "burst-aware";
|
|
52
|
+
rate?: number;
|
|
53
|
+
burst?: number;
|
|
54
|
+
windowSeconds?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
44
57
|
type MethodMap = {
|
|
45
58
|
[K in keyof QuickbaseClient]: MethodInfo<K>;
|
|
46
59
|
};
|
|
@@ -50,35 +63,45 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
|
|
|
50
63
|
realm,
|
|
51
64
|
userToken,
|
|
52
65
|
tempToken,
|
|
53
|
-
useTempTokens,
|
|
54
|
-
useSso,
|
|
66
|
+
useTempTokens = false,
|
|
67
|
+
useSso = false,
|
|
55
68
|
samlToken,
|
|
56
69
|
fetchApi,
|
|
57
70
|
debug,
|
|
58
71
|
convertDates = true,
|
|
59
72
|
tempTokenLifespan = 290000,
|
|
60
|
-
throttle = { rate:
|
|
73
|
+
throttle = { type: "flow", rate: 5, burst: 50 }, // Changed rate from 6 to 5
|
|
61
74
|
maxRetries = 3,
|
|
62
75
|
retryDelay = 1000,
|
|
63
76
|
tokenCache: providedTokenCache,
|
|
64
77
|
baseUrl = "https://api.quickbase.com/v1",
|
|
78
|
+
autoPaginate = true,
|
|
65
79
|
} = config;
|
|
66
80
|
|
|
67
81
|
const tokenCache = providedTokenCache || new TokenCache(tempTokenLifespan);
|
|
68
|
-
const throttleBucket = throttle
|
|
69
|
-
? new ThrottleBucket(throttle.rate, throttle.burst)
|
|
70
|
-
: null;
|
|
71
|
-
const rateLimiter = new RateLimiter(throttleBucket, maxRetries, retryDelay);
|
|
72
82
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
const throttleOptions = throttle as ThrottleOptions;
|
|
84
|
+
const throttleBucket =
|
|
85
|
+
throttleOptions.type === "burst-aware"
|
|
86
|
+
? new BurstAwareThrottleBucket({
|
|
87
|
+
maxTokens: throttleOptions.burst || 100,
|
|
88
|
+
windowSeconds: throttleOptions.windowSeconds || 10,
|
|
89
|
+
})
|
|
90
|
+
: new FlowThrottleBucket(
|
|
91
|
+
throttleOptions.rate || 5, // Changed rate from 6 to 5
|
|
92
|
+
throttleOptions.burst || 50
|
|
93
|
+
);
|
|
78
94
|
|
|
79
|
-
|
|
80
|
-
|
|
95
|
+
const rateLimiter = new RateLimiter(throttleBucket, maxRetries, retryDelay);
|
|
96
|
+
|
|
97
|
+
const defaultFetch: typeof fetch =
|
|
98
|
+
globalThis.fetch || globalThis.window?.fetch?.bind(globalThis.window);
|
|
99
|
+
if (!defaultFetch && !fetchApi) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"No fetch implementation available. Please provide fetchApi in a Node.js environment without native fetch."
|
|
102
|
+
);
|
|
81
103
|
}
|
|
104
|
+
const effectiveFetch = fetchApi || defaultFetch;
|
|
82
105
|
|
|
83
106
|
const authStrategy: AuthorizationStrategy = useSso
|
|
84
107
|
? new SsoTokenStrategy(
|
|
@@ -160,14 +183,16 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
|
|
|
160
183
|
|
|
161
184
|
const methodMap = buildMethodMap();
|
|
162
185
|
|
|
163
|
-
const proxyHandler: ProxyHandler<QuickbaseClient> = {
|
|
164
|
-
get: (_, prop: string) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
const proxyHandler: globalThis.ProxyHandler<QuickbaseClient> = {
|
|
187
|
+
get: (_: QuickbaseClient, prop: string) => {
|
|
188
|
+
if (debug) {
|
|
189
|
+
console.log(
|
|
190
|
+
"[proxy] Accessing:",
|
|
191
|
+
prop,
|
|
192
|
+
"in methodMap:",
|
|
193
|
+
prop in methodMap
|
|
194
|
+
);
|
|
195
|
+
}
|
|
171
196
|
if (prop in methodMap) {
|
|
172
197
|
const methodName = prop as keyof QuickbaseClient;
|
|
173
198
|
return (params: Parameters<QuickbaseClient[typeof methodName]>[0]) =>
|
|
@@ -180,10 +205,13 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
|
|
|
180
205
|
rateLimiter,
|
|
181
206
|
transformDates,
|
|
182
207
|
debug,
|
|
183
|
-
convertDates
|
|
208
|
+
convertDates,
|
|
209
|
+
autoPaginate
|
|
184
210
|
);
|
|
185
211
|
}
|
|
186
|
-
|
|
212
|
+
if (debug) {
|
|
213
|
+
console.log("[proxy] Method not found:", prop);
|
|
214
|
+
}
|
|
187
215
|
return undefined;
|
|
188
216
|
},
|
|
189
217
|
};
|
|
@@ -192,7 +220,7 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
|
|
|
192
220
|
|
|
193
221
|
if (debug) {
|
|
194
222
|
console.log("[createClient] Config:", config);
|
|
195
|
-
console.log("[createClient]
|
|
223
|
+
console.log("[createClient] Returning proxy");
|
|
196
224
|
}
|
|
197
225
|
|
|
198
226
|
return proxy;
|
package/src/rateLimiter.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import { ThrottleBucket } from "./ThrottleBucket";
|
|
1
|
+
// src/RateLimiter.ts
|
|
3
2
|
import { ResponseError } from "./generated/runtime";
|
|
3
|
+
import { RateThrottleBucket } from "./RateThrottleBucket";
|
|
4
4
|
|
|
5
5
|
export class RateLimiter {
|
|
6
6
|
constructor(
|
|
7
|
-
private throttleBucket:
|
|
7
|
+
private throttleBucket: RateThrottleBucket | null,
|
|
8
8
|
public maxRetries: number = 3,
|
|
9
9
|
private retryDelay: number = 1000
|
|
10
10
|
) {}
|
|
@@ -15,8 +15,13 @@ export class RateLimiter {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
release(): void {
|
|
19
|
+
if (this.throttleBucket) {
|
|
20
|
+
this.throttleBucket.release();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
async handle429(error: ResponseError, attempt: number): Promise<number> {
|
|
19
|
-
// Remove the throw here; let invokeMethod handle exhaustion
|
|
20
25
|
const retryAfter = error.response.headers.get("Retry-After");
|
|
21
26
|
return retryAfter
|
|
22
27
|
? parseInt(retryAfter, 10) * 1000
|
|
@@ -28,7 +28,7 @@ describe("QuickbaseClient Integration - getFieldsUsage", () => {
|
|
|
28
28
|
console.log(
|
|
29
29
|
`Retrieved ${response.length} field usage entries for table ${QB_TABLE_ID_1}`
|
|
30
30
|
);
|
|
31
|
-
},
|
|
31
|
+
}, 30000); // 15-second timeout
|
|
32
32
|
|
|
33
33
|
it("retrieves field usage with skip and top parameters", async () => {
|
|
34
34
|
console.log(
|
|
@@ -73,5 +73,5 @@ describe("QuickbaseClient Integration - getFieldsUsage", () => {
|
|
|
73
73
|
),
|
|
74
74
|
});
|
|
75
75
|
console.log(`Confirmed 401 for invalid table ID ${invalidTableId}`);
|
|
76
|
-
},
|
|
76
|
+
}, 30000); // 15-second timeout
|
|
77
77
|
});
|