quickbase-js 2.3.0 → 2.4.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/README.md +145 -1107
- package/dist/auth/ticket.d.ts.map +1 -1
- package/dist/auth/ticket.js +4 -3
- package/dist/auth/ticket.js.map +1 -1
- package/dist/client/request.d.ts +4 -0
- package/dist/client/request.d.ts.map +1 -1
- package/dist/client/request.js +15 -3
- package/dist/client/request.js.map +1 -1
- package/dist/generated/client.d.ts +2 -0
- package/dist/generated/client.d.ts.map +1 -1
- package/dist/generated/client.js +134 -113
- package/dist/generated/client.js.map +1 -1
- package/dist/generated/types.d.ts +2700 -2256
- package/dist/generated/types.d.ts.map +1 -1
- package/dist/generated/types.js +0 -2
- package/dist/generated/types.js.map +1 -1
- package/dist/quickbase.esm.js +3 -3
- package/dist/quickbase.esm.js.map +3 -3
- package/dist/quickbase.min.js +3 -3
- package/dist/quickbase.min.js.map +3 -3
- package/package.json +16 -13
- package/src/auth/index.ts +55 -0
- package/src/auth/sso-token.ts +143 -0
- package/src/auth/temp-token.ts +160 -0
- package/src/auth/ticket.ts +329 -0
- package/src/auth/token-cache.ts +97 -0
- package/src/auth/types.ts +50 -0
- package/src/auth/user-token.ts +38 -0
- package/src/cli/schema.ts +512 -0
- package/src/client/index.ts +36 -0
- package/src/client/pagination.ts +337 -0
- package/src/client/request.ts +544 -0
- package/src/client/retry.ts +169 -0
- package/src/client/throttle.ts +105 -0
- package/src/core/config.ts +160 -0
- package/src/core/dates.ts +63 -0
- package/src/core/errors.ts +349 -0
- package/src/core/index.ts +50 -0
- package/src/core/logger.ts +113 -0
- package/src/core/schema.ts +213 -0
- package/src/core/transform.ts +295 -0
- package/src/core/types.ts +337 -0
- package/src/generated/client.ts +1785 -0
- package/src/generated/operations.json +672 -0
- package/src/generated/types.ts +4884 -0
- package/src/index.ts +259 -0
- package/src/xml/codepages.ts +119 -0
- package/src/xml/dbvars.ts +75 -0
- package/src/xml/errors.ts +133 -0
- package/src/xml/fields.ts +121 -0
- package/src/xml/groups.ts +560 -0
- package/src/xml/html.ts +152 -0
- package/src/xml/index.ts +1196 -0
- package/src/xml/metadata.ts +111 -0
- package/src/xml/records.ts +301 -0
- package/src/xml/request.ts +238 -0
- package/src/xml/roles.ts +321 -0
- package/src/xml/schema.ts +436 -0
- package/src/xml/transform.ts +381 -0
- package/src/xml/types.ts +602 -0
- package/src/xml/users.ts +221 -0
- package/src/xml/webhooks.ts +260 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request execution module
|
|
3
|
+
*
|
|
4
|
+
* Handles the full request lifecycle:
|
|
5
|
+
* 1. Authentication
|
|
6
|
+
* 2. Throttling (if enabled)
|
|
7
|
+
* 3. Request execution
|
|
8
|
+
* 4. Error handling with retry
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RequestContext, ResolvedConfig } from '../core/types.js';
|
|
12
|
+
import type { AuthStrategy } from '../auth/types.js';
|
|
13
|
+
import type { Logger } from '../core/logger.js';
|
|
14
|
+
import type { Throttle } from './throttle.js';
|
|
15
|
+
import {
|
|
16
|
+
QuickbaseError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
TimeoutError,
|
|
19
|
+
ReadOnlyError,
|
|
20
|
+
parseErrorResponse,
|
|
21
|
+
isRetryableError,
|
|
22
|
+
} from '../core/errors.js';
|
|
23
|
+
import { extractRateLimitInfo, sleep } from './retry.js';
|
|
24
|
+
import { getRealmHostname } from '../core/config.js';
|
|
25
|
+
import { transformDates } from '../core/dates.js';
|
|
26
|
+
import {
|
|
27
|
+
transformRequest,
|
|
28
|
+
transformResponse,
|
|
29
|
+
extractTableIdFromRequest,
|
|
30
|
+
} from '../core/transform.js';
|
|
31
|
+
import { resolveTableAlias } from '../core/schema.js';
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// Read-Only Mode - Defense-in-Depth
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* JSON API endpoints that modify data.
|
|
39
|
+
* Layer 1 of defense-in-depth - explicit blocklist.
|
|
40
|
+
* Uses "METHOD /path" for exact match, "METHOD /path/" for prefix match.
|
|
41
|
+
* Note: Paths do not include /v1 prefix as the SDK uses relative paths.
|
|
42
|
+
*/
|
|
43
|
+
const jsonWriteEndpoints: Record<string, boolean> = {
|
|
44
|
+
// Records
|
|
45
|
+
'POST /records': true, // Upsert
|
|
46
|
+
'DELETE /records': true, // DeleteRecords
|
|
47
|
+
|
|
48
|
+
// Apps
|
|
49
|
+
'POST /apps': true, // CreateApp
|
|
50
|
+
'DELETE /apps': true, // DeleteApp (prefix)
|
|
51
|
+
'POST /apps/': true, // UpdateApp, CopyApp (prefix)
|
|
52
|
+
'DELETE /apps/': true, // DeleteApp (prefix)
|
|
53
|
+
|
|
54
|
+
// Tables
|
|
55
|
+
'POST /tables': true, // CreateTable
|
|
56
|
+
'DELETE /tables': true, // DeleteTable (prefix)
|
|
57
|
+
'POST /tables/': true, // UpdateTable, relationships (prefix)
|
|
58
|
+
'DELETE /tables/': true, // DeleteRelationship (prefix)
|
|
59
|
+
|
|
60
|
+
// Fields
|
|
61
|
+
'POST /fields': true, // CreateField
|
|
62
|
+
'DELETE /fields': true, // DeleteFields
|
|
63
|
+
'POST /fields/': true, // UpdateField (prefix)
|
|
64
|
+
|
|
65
|
+
// Files
|
|
66
|
+
'DELETE /files/': true, // DeleteFile (prefix)
|
|
67
|
+
|
|
68
|
+
// User tokens
|
|
69
|
+
'POST /usertoken': true, // Clone, Transfer, Deactivate
|
|
70
|
+
'DELETE /usertoken': true, // Delete
|
|
71
|
+
|
|
72
|
+
// Users and groups
|
|
73
|
+
'PUT /users': true, // DenyUsers, UndenyUsers
|
|
74
|
+
'PUT /users/': true, // DenyUsersAndGroups (prefix)
|
|
75
|
+
'POST /groups/': true, // AddMembers, AddManagers, AddSubgroups (prefix)
|
|
76
|
+
'DELETE /groups/': true, // RemoveMembers, RemoveManagers, RemoveSubgroups (prefix)
|
|
77
|
+
|
|
78
|
+
// Solutions
|
|
79
|
+
'POST /solutions': true, // CreateSolution
|
|
80
|
+
'PUT /solutions/': true, // UpdateSolution, ChangesetSolution (prefix)
|
|
81
|
+
|
|
82
|
+
// Document generation (GET but writes)
|
|
83
|
+
'GET /docTemplates/': true, // GenerateDocument (prefix)
|
|
84
|
+
|
|
85
|
+
// Solution from record (GET but writes)
|
|
86
|
+
'GET /solutions/fromrecord': true, // CreateSolutionFromRecord
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* POST endpoints that are actually read-only (allowlist).
|
|
91
|
+
* These are exceptions to the "POST = write" rule.
|
|
92
|
+
* Note: Paths do not include /v1 prefix as the SDK uses relative paths.
|
|
93
|
+
*/
|
|
94
|
+
const jsonReadOnlyPOSTEndpoints: Record<string, boolean> = {
|
|
95
|
+
'POST /records/query': true, // RunQuery - read-only
|
|
96
|
+
'POST /reports/': true, // RunReport - read-only (prefix)
|
|
97
|
+
'POST /formula/run': true, // RunFormula - read-only
|
|
98
|
+
'POST /audit': true, // Audit logs - read-only
|
|
99
|
+
'POST /users': true, // GetUsers - read-only despite POST
|
|
100
|
+
'POST /analytics/': true, // Analytics - read-only (prefix)
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if HTTP method is a write operation.
|
|
105
|
+
*/
|
|
106
|
+
function isWriteMethod(method: string): boolean {
|
|
107
|
+
const upper = method.toUpperCase();
|
|
108
|
+
return upper === 'POST' || upper === 'PUT' || upper === 'DELETE' || upper === 'PATCH';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if request matches a known write endpoint.
|
|
113
|
+
* Uses both exact matching and prefix matching for paths with IDs.
|
|
114
|
+
*/
|
|
115
|
+
function isJSONWriteEndpoint(method: string, path: string): boolean {
|
|
116
|
+
const key = `${method} ${path}`;
|
|
117
|
+
|
|
118
|
+
// Exact match
|
|
119
|
+
if (jsonWriteEndpoints[key]) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Prefix match for paths with IDs (e.g., "POST /v1/apps/" matches "POST /v1/apps/abc123")
|
|
124
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
125
|
+
if (path[i] === '/') {
|
|
126
|
+
const prefixKey = `${method} ${path.slice(0, i + 1)}`;
|
|
127
|
+
if (jsonWriteEndpoints[prefixKey]) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if a POST request is actually read-only (allowlist).
|
|
138
|
+
*/
|
|
139
|
+
function isJSONReadOnlyPOSTEndpoint(path: string): boolean {
|
|
140
|
+
const key = `POST ${path}`;
|
|
141
|
+
|
|
142
|
+
// Exact match
|
|
143
|
+
if (jsonReadOnlyPOSTEndpoints[key]) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Prefix match
|
|
148
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
149
|
+
if (path[i] === '/') {
|
|
150
|
+
const prefixKey = `POST ${path.slice(0, i + 1)}`;
|
|
151
|
+
if (jsonReadOnlyPOSTEndpoints[prefixKey]) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check if request is blocked by read-only mode.
|
|
162
|
+
* Uses two layers of defense:
|
|
163
|
+
* 1. Explicit blocklist of known write endpoints
|
|
164
|
+
* 2. HTTP method check (catch-all for POST/PUT/DELETE/PATCH)
|
|
165
|
+
*/
|
|
166
|
+
function checkReadOnly(config: ResolvedConfig, method: string, path: string): void {
|
|
167
|
+
if (!config.readOnly) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Layer 1: Explicit blocklist check (defense-in-depth)
|
|
172
|
+
if (isJSONWriteEndpoint(method, path)) {
|
|
173
|
+
// Exception: Some POST endpoints are read-only (RunQuery, RunReport, etc.)
|
|
174
|
+
if (method === 'POST' && isJSONReadOnlyPOSTEndpoint(path)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw new ReadOnlyError(method, path);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Layer 2: HTTP method check (catch-all for any endpoints not in blocklist)
|
|
181
|
+
if (isWriteMethod(method)) {
|
|
182
|
+
// Exception: Some POST endpoints are read-only
|
|
183
|
+
if (method === 'POST' && isJSONReadOnlyPOSTEndpoint(path)) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
throw new ReadOnlyError(method, path);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// Request Types
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
export interface RequestOptions {
|
|
195
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
196
|
+
path: string;
|
|
197
|
+
body?: unknown;
|
|
198
|
+
/** Content type of a body that isn't JSON (QBL is application/x-yaml); the body is sent as given */
|
|
199
|
+
bodyType?: string;
|
|
200
|
+
/** 'text' returns the response body unparsed (QBL, file content) instead of parsing JSON */
|
|
201
|
+
responseType?: 'json' | 'text';
|
|
202
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
203
|
+
/** Database ID for temp token auth */
|
|
204
|
+
dbid?: string;
|
|
205
|
+
/** Custom headers */
|
|
206
|
+
headers?: Record<string, string>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface RequestExecutorOptions {
|
|
210
|
+
config: ResolvedConfig;
|
|
211
|
+
auth: AuthStrategy;
|
|
212
|
+
throttle: Throttle;
|
|
213
|
+
logger: Logger;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Extract dbid from request options for temp-token auth.
|
|
218
|
+
* Checks multiple locations where table/app ID may appear.
|
|
219
|
+
* Resolves aliases to IDs if schema is provided.
|
|
220
|
+
*/
|
|
221
|
+
export function extractDbid(
|
|
222
|
+
options: RequestOptions,
|
|
223
|
+
config?: ResolvedConfig
|
|
224
|
+
): string | undefined {
|
|
225
|
+
// 1. Explicit dbid takes priority
|
|
226
|
+
if (options.dbid) {
|
|
227
|
+
return config?.schema
|
|
228
|
+
? resolveTableAlias(config.schema, options.dbid)
|
|
229
|
+
: options.dbid;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 2. Check query params for tableId
|
|
233
|
+
if (options.query?.tableId) {
|
|
234
|
+
const tableId = String(options.query.tableId);
|
|
235
|
+
return config?.schema ? resolveTableAlias(config.schema, tableId) : tableId;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 3. Check query params for appId (for app-level operations)
|
|
239
|
+
if (options.query?.appId) {
|
|
240
|
+
return String(options.query.appId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 4. Check path for tableId (e.g., /v1/tables/{tableId}/...)
|
|
244
|
+
const tableIdMatch = options.path.match(/\/tables\/([^/?]+)/);
|
|
245
|
+
if (tableIdMatch) {
|
|
246
|
+
return tableIdMatch[1];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 5. Check path for appId (e.g., /v1/apps/{appId}/...)
|
|
250
|
+
const appIdMatch = options.path.match(/\/apps\/([^/?]+)/);
|
|
251
|
+
if (appIdMatch) {
|
|
252
|
+
return appIdMatch[1];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 6. Check request body for 'from' or 'to' fields
|
|
256
|
+
if (options.body && typeof options.body === 'object') {
|
|
257
|
+
const tableId = extractTableIdFromRequest(
|
|
258
|
+
options.body as Record<string, unknown>,
|
|
259
|
+
config?.schema
|
|
260
|
+
);
|
|
261
|
+
if (tableId) return tableId;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build URL with query parameters
|
|
269
|
+
*/
|
|
270
|
+
function buildUrl(
|
|
271
|
+
baseUrl: string,
|
|
272
|
+
path: string,
|
|
273
|
+
query?: Record<string, string | number | boolean | undefined>
|
|
274
|
+
): string {
|
|
275
|
+
// Ensure baseUrl ends with / and path doesn't start with / to properly join them
|
|
276
|
+
// new URL('/apps/x', 'https://api.quickbase.com/v1') would give https://api.quickbase.com/apps/x (wrong)
|
|
277
|
+
// new URL('apps/x', 'https://api.quickbase.com/v1/') gives https://api.quickbase.com/v1/apps/x (correct)
|
|
278
|
+
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
279
|
+
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
|
280
|
+
const url = new URL(normalizedPath, normalizedBase);
|
|
281
|
+
|
|
282
|
+
if (query) {
|
|
283
|
+
for (const [key, value] of Object.entries(query)) {
|
|
284
|
+
if (value !== undefined) {
|
|
285
|
+
url.searchParams.set(key, String(value));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return url.toString();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Create request headers
|
|
295
|
+
*/
|
|
296
|
+
async function buildHeaders(
|
|
297
|
+
auth: AuthStrategy,
|
|
298
|
+
realmHostname: string,
|
|
299
|
+
dbid?: string,
|
|
300
|
+
customHeaders?: Record<string, string>
|
|
301
|
+
): Promise<Record<string, string>> {
|
|
302
|
+
const token = await auth.getToken(dbid);
|
|
303
|
+
const authHeader = auth.getAuthorizationHeader(token);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
'Content-Type': 'application/json',
|
|
307
|
+
'QB-Realm-Hostname': realmHostname,
|
|
308
|
+
'Authorization': authHeader,
|
|
309
|
+
...customHeaders,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Execute a single request (no retry logic)
|
|
315
|
+
*/
|
|
316
|
+
async function executeSingleRequest(
|
|
317
|
+
options: RequestOptions,
|
|
318
|
+
executor: RequestExecutorOptions,
|
|
319
|
+
context: RequestContext
|
|
320
|
+
): Promise<Response> {
|
|
321
|
+
const { config, auth, throttle, logger } = executor;
|
|
322
|
+
const url = buildUrl(config.baseUrl, options.path, options.query);
|
|
323
|
+
const realmHostname = getRealmHostname(config.realm);
|
|
324
|
+
|
|
325
|
+
// Get auth headers (use extracted dbid from context)
|
|
326
|
+
const headers = await buildHeaders(auth, realmHostname, context.dbid, options.headers);
|
|
327
|
+
|
|
328
|
+
// Apply throttling if enabled
|
|
329
|
+
await throttle.acquire();
|
|
330
|
+
|
|
331
|
+
// Build fetch options
|
|
332
|
+
// Use credentials: 'omit' to avoid CORS issues (temp token fetch uses 'include' separately)
|
|
333
|
+
const fetchOptions: RequestInit = {
|
|
334
|
+
method: options.method,
|
|
335
|
+
headers,
|
|
336
|
+
credentials: 'omit',
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (options.body !== undefined) {
|
|
340
|
+
if (options.bodyType) {
|
|
341
|
+
headers['Content-Type'] = options.bodyType;
|
|
342
|
+
fetchOptions.body = options.body as string;
|
|
343
|
+
} else {
|
|
344
|
+
fetchOptions.body = JSON.stringify(options.body);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Create abort controller for timeout
|
|
349
|
+
const controller = new AbortController();
|
|
350
|
+
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
|
351
|
+
fetchOptions.signal = controller.signal;
|
|
352
|
+
|
|
353
|
+
const startTime = Date.now();
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const response = await config.fetchApi(url, fetchOptions);
|
|
357
|
+
const duration = Date.now() - startTime;
|
|
358
|
+
|
|
359
|
+
logger.timing(options.method, options.path, duration);
|
|
360
|
+
|
|
361
|
+
return response;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
364
|
+
throw new TimeoutError(config.timeout);
|
|
365
|
+
}
|
|
366
|
+
throw error;
|
|
367
|
+
} finally {
|
|
368
|
+
clearTimeout(timeoutId);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Handle response and throw appropriate errors
|
|
374
|
+
*/
|
|
375
|
+
async function handleResponse(
|
|
376
|
+
response: Response,
|
|
377
|
+
requestUrl: string,
|
|
378
|
+
context: RequestContext,
|
|
379
|
+
executor: RequestExecutorOptions
|
|
380
|
+
): Promise<Response> {
|
|
381
|
+
const { config, logger } = executor;
|
|
382
|
+
|
|
383
|
+
if (response.ok) {
|
|
384
|
+
return response;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Extract rate limit info for 429s
|
|
388
|
+
if (response.status === 429) {
|
|
389
|
+
const rateLimitInfo = extractRateLimitInfo(response, requestUrl, context.attempt);
|
|
390
|
+
|
|
391
|
+
logger.rateLimit({
|
|
392
|
+
url: requestUrl,
|
|
393
|
+
status: 429,
|
|
394
|
+
retryAfter: rateLimitInfo.retryAfter,
|
|
395
|
+
attempt: context.attempt,
|
|
396
|
+
rayId: rateLimitInfo.qbApiRay || rateLimitInfo.cfRay,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Notify callback
|
|
400
|
+
if (config.onRateLimit) {
|
|
401
|
+
config.onRateLimit(rateLimitInfo);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
throw new RateLimitError(rateLimitInfo);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Parse and throw appropriate error
|
|
408
|
+
throw await parseErrorResponse(response, requestUrl);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Execute a request with full retry logic
|
|
413
|
+
*/
|
|
414
|
+
export async function executeRequest<T = unknown>(
|
|
415
|
+
options: RequestOptions,
|
|
416
|
+
executor: RequestExecutorOptions
|
|
417
|
+
): Promise<T> {
|
|
418
|
+
const { config, auth, logger } = executor;
|
|
419
|
+
const maxAttempts = config.retry.maxAttempts;
|
|
420
|
+
|
|
421
|
+
// Check read-only mode before making any request
|
|
422
|
+
checkReadOnly(config, options.method, options.path);
|
|
423
|
+
|
|
424
|
+
// Extract dbid for temp-token auth (resolves aliases if schema provided)
|
|
425
|
+
const dbid = extractDbid(options, config);
|
|
426
|
+
|
|
427
|
+
// Transform the request body: resolve schema aliases when there is a
|
|
428
|
+
// schema, and wrap plain record values ({ '6': 'x' } -> { '6': { value: 'x' } })
|
|
429
|
+
// always. Raw (non-JSON) bodies are strings and pass through.
|
|
430
|
+
let transformedOptions = options;
|
|
431
|
+
if (options.body && typeof options.body === 'object' && !Array.isArray(options.body)) {
|
|
432
|
+
const transformedBody = transformRequest(
|
|
433
|
+
options.body as Record<string, unknown>,
|
|
434
|
+
{ schema: config.schema, tableId: dbid }
|
|
435
|
+
);
|
|
436
|
+
transformedOptions = { ...options, body: transformedBody };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const context: RequestContext = {
|
|
440
|
+
methodName: `${options.method} ${options.path}`,
|
|
441
|
+
attempt: 1,
|
|
442
|
+
maxAttempts,
|
|
443
|
+
dbid,
|
|
444
|
+
startTime: Date.now(),
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
let lastError: Error | null = null;
|
|
448
|
+
|
|
449
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
450
|
+
context.attempt = attempt;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const response = await executeSingleRequest(transformedOptions, executor, context);
|
|
454
|
+
const url = buildUrl(config.baseUrl, options.path, options.query);
|
|
455
|
+
|
|
456
|
+
await handleResponse(response, url, context, executor);
|
|
457
|
+
|
|
458
|
+
// A response that isn't JSON (QBL, file content) comes back as text
|
|
459
|
+
if (options.responseType === 'text') {
|
|
460
|
+
return (await response.text()) as T;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Parse JSON response
|
|
464
|
+
let data = await response.json();
|
|
465
|
+
|
|
466
|
+
// Transform response if schema is configured
|
|
467
|
+
if (config.schema && dbid) {
|
|
468
|
+
data = transformResponse(data, { schema: config.schema, tableId: dbid });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Optionally transform dates
|
|
472
|
+
return transformDates(data, config.convertDates) as T;
|
|
473
|
+
} catch (error) {
|
|
474
|
+
lastError = error as Error;
|
|
475
|
+
|
|
476
|
+
// Check if error is retryable
|
|
477
|
+
if (!isRetryableError(error)) {
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Don't retry if we've exhausted attempts
|
|
482
|
+
if (attempt >= maxAttempts) {
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Handle auth errors specially - try to refresh token
|
|
487
|
+
if (error instanceof QuickbaseError && error.statusCode === 401) {
|
|
488
|
+
const refreshed = await auth.handleAuthError(dbid);
|
|
489
|
+
if (!refreshed) {
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
logger.debug('Token refreshed, retrying request');
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Calculate retry delay
|
|
497
|
+
let delayMs: number;
|
|
498
|
+
let reason: string;
|
|
499
|
+
|
|
500
|
+
if (error instanceof RateLimitError) {
|
|
501
|
+
delayMs = error.retryAfter
|
|
502
|
+
? error.retryAfter * 1000
|
|
503
|
+
: calculateBackoff(attempt, config.retry);
|
|
504
|
+
reason = 'Rate limited (429)';
|
|
505
|
+
} else if (error instanceof TimeoutError) {
|
|
506
|
+
delayMs = calculateBackoff(attempt, config.retry);
|
|
507
|
+
reason = 'Timeout';
|
|
508
|
+
} else if (error instanceof QuickbaseError && error.statusCode >= 500) {
|
|
509
|
+
delayMs = calculateBackoff(attempt, config.retry);
|
|
510
|
+
reason = `Server error (${error.statusCode})`;
|
|
511
|
+
} else {
|
|
512
|
+
delayMs = calculateBackoff(attempt, config.retry);
|
|
513
|
+
reason = 'Retryable error';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
logger.retry(attempt, maxAttempts, delayMs, reason);
|
|
517
|
+
await sleep(delayMs);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
throw lastError || new Error('Request failed');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Calculate exponential backoff delay with jitter
|
|
526
|
+
*/
|
|
527
|
+
function calculateBackoff(
|
|
528
|
+
attempt: number,
|
|
529
|
+
config: { initialDelayMs: number; maxDelayMs: number; multiplier: number }
|
|
530
|
+
): number {
|
|
531
|
+
const delay = config.initialDelayMs * Math.pow(config.multiplier, attempt - 1);
|
|
532
|
+
const jitter = delay * 0.1 * (Math.random() * 2 - 1);
|
|
533
|
+
return Math.min(delay + jitter, config.maxDelayMs);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Create a request executor
|
|
538
|
+
*/
|
|
539
|
+
export function createRequestExecutor(options: RequestExecutorOptions) {
|
|
540
|
+
return {
|
|
541
|
+
execute: <T = unknown>(requestOptions: RequestOptions) =>
|
|
542
|
+
executeRequest<T>(requestOptions, options),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry logic with exponential backoff
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - HTTP 429 (Rate Limited) - uses Retry-After header
|
|
6
|
+
* - HTTP 5xx (Server Errors) - exponential backoff
|
|
7
|
+
* - Timeout errors - exponential backoff
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RequestContext, RateLimitInfo, ResolvedConfig } from '../core/types.js';
|
|
11
|
+
import type { Logger } from '../core/logger.js';
|
|
12
|
+
import {
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServerError,
|
|
15
|
+
TimeoutError,
|
|
16
|
+
isRetryableError,
|
|
17
|
+
} from '../core/errors.js';
|
|
18
|
+
|
|
19
|
+
export interface RetryOptions {
|
|
20
|
+
maxAttempts: number;
|
|
21
|
+
initialDelayMs: number;
|
|
22
|
+
maxDelayMs: number;
|
|
23
|
+
multiplier: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Calculate delay for exponential backoff
|
|
28
|
+
*/
|
|
29
|
+
export function calculateBackoffDelay(
|
|
30
|
+
attempt: number,
|
|
31
|
+
options: RetryOptions
|
|
32
|
+
): number {
|
|
33
|
+
// Exponential backoff: initialDelay * multiplier^(attempt-1)
|
|
34
|
+
const delay = options.initialDelayMs * Math.pow(options.multiplier, attempt - 1);
|
|
35
|
+
|
|
36
|
+
// Add jitter (±10%) to prevent thundering herd
|
|
37
|
+
const jitter = delay * 0.1 * (Math.random() * 2 - 1);
|
|
38
|
+
|
|
39
|
+
return Math.min(delay + jitter, options.maxDelayMs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get delay from Retry-After header or fall back to backoff calculation
|
|
44
|
+
*/
|
|
45
|
+
export function getRetryDelay(
|
|
46
|
+
response: Response | null,
|
|
47
|
+
attempt: number,
|
|
48
|
+
options: RetryOptions
|
|
49
|
+
): number {
|
|
50
|
+
if (response) {
|
|
51
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
52
|
+
if (retryAfter) {
|
|
53
|
+
// Retry-After can be seconds or a date string
|
|
54
|
+
const seconds = parseInt(retryAfter, 10);
|
|
55
|
+
if (!isNaN(seconds)) {
|
|
56
|
+
return seconds * 1000;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Try parsing as a date
|
|
60
|
+
const date = Date.parse(retryAfter);
|
|
61
|
+
if (!isNaN(date)) {
|
|
62
|
+
return Math.max(0, date - Date.now());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return calculateBackoffDelay(attempt, options);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract rate limit info from response headers
|
|
72
|
+
*/
|
|
73
|
+
export function extractRateLimitInfo(
|
|
74
|
+
response: Response,
|
|
75
|
+
requestUrl: string,
|
|
76
|
+
attempt: number
|
|
77
|
+
): RateLimitInfo {
|
|
78
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
timestamp: new Date(),
|
|
82
|
+
requestUrl,
|
|
83
|
+
httpStatus: response.status,
|
|
84
|
+
retryAfter: retryAfter ? parseInt(retryAfter, 10) : undefined,
|
|
85
|
+
cfRay: response.headers.get('cf-ray') || undefined,
|
|
86
|
+
tid: response.headers.get('tid') || undefined,
|
|
87
|
+
qbApiRay: response.headers.get('qb-api-ray') || undefined,
|
|
88
|
+
attempt,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sleep for a given duration
|
|
94
|
+
*/
|
|
95
|
+
export function sleep(ms: number): Promise<void> {
|
|
96
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Determine if a response status is retryable
|
|
101
|
+
*/
|
|
102
|
+
export function isRetryableStatus(status: number): boolean {
|
|
103
|
+
return status === 429 || (status >= 500 && status < 600);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Execute a request with retry logic
|
|
108
|
+
*/
|
|
109
|
+
export async function executeWithRetry<T>(
|
|
110
|
+
fn: (context: RequestContext) => Promise<T>,
|
|
111
|
+
context: RequestContext,
|
|
112
|
+
config: ResolvedConfig,
|
|
113
|
+
logger: Logger
|
|
114
|
+
): Promise<T> {
|
|
115
|
+
const options: RetryOptions = config.retry;
|
|
116
|
+
let lastError: Error | null = null;
|
|
117
|
+
|
|
118
|
+
for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
|
|
119
|
+
context.attempt = attempt;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
return await fn(context);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
lastError = error as Error;
|
|
125
|
+
|
|
126
|
+
// Check if error is retryable
|
|
127
|
+
if (!isRetryableError(error)) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Don't retry if we've exhausted attempts
|
|
132
|
+
if (attempt >= options.maxAttempts) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Calculate retry delay
|
|
137
|
+
let delayMs: number;
|
|
138
|
+
let reason: string;
|
|
139
|
+
|
|
140
|
+
if (error instanceof RateLimitError) {
|
|
141
|
+
delayMs = error.retryAfter
|
|
142
|
+
? error.retryAfter * 1000
|
|
143
|
+
: calculateBackoffDelay(attempt, options);
|
|
144
|
+
reason = `Rate limited (429)`;
|
|
145
|
+
|
|
146
|
+
// Notify callback
|
|
147
|
+
if (config.onRateLimit) {
|
|
148
|
+
config.onRateLimit(error.rateLimitInfo);
|
|
149
|
+
}
|
|
150
|
+
} else if (error instanceof ServerError) {
|
|
151
|
+
delayMs = calculateBackoffDelay(attempt, options);
|
|
152
|
+
reason = `Server error (${error.statusCode})`;
|
|
153
|
+
} else if (error instanceof TimeoutError) {
|
|
154
|
+
delayMs = calculateBackoffDelay(attempt, options);
|
|
155
|
+
reason = `Timeout`;
|
|
156
|
+
} else {
|
|
157
|
+
delayMs = calculateBackoffDelay(attempt, options);
|
|
158
|
+
reason = `Unknown retryable error`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logger.retry(attempt, options.maxAttempts, delayMs, reason);
|
|
162
|
+
|
|
163
|
+
await sleep(delayMs);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// This should never be reached, but TypeScript needs it
|
|
168
|
+
throw lastError || new Error('Retry loop exited unexpectedly');
|
|
169
|
+
}
|