quickbase-js 2.0.0 → 2.2.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.
Files changed (113) hide show
  1. package/README.md +804 -59
  2. package/bin/quickbase-js.js +54 -0
  3. package/dist/auth/index.d.ts +1 -0
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +9 -0
  6. package/dist/auth/index.js.map +1 -1
  7. package/dist/auth/ticket.d.ts +84 -0
  8. package/dist/auth/ticket.d.ts.map +1 -0
  9. package/dist/auth/ticket.js +260 -0
  10. package/dist/auth/ticket.js.map +1 -0
  11. package/dist/cli/schema.d.ts +23 -0
  12. package/dist/cli/schema.d.ts.map +1 -0
  13. package/dist/cli/schema.js +425 -0
  14. package/dist/cli/schema.js.map +1 -0
  15. package/dist/client/request.d.ts +2 -1
  16. package/dist/client/request.d.ts.map +1 -1
  17. package/dist/client/request.js +165 -18
  18. package/dist/client/request.js.map +1 -1
  19. package/dist/core/config.d.ts.map +1 -1
  20. package/dist/core/config.js +12 -0
  21. package/dist/core/config.js.map +1 -1
  22. package/dist/core/errors.d.ts +17 -0
  23. package/dist/core/errors.d.ts.map +1 -1
  24. package/dist/core/errors.js +32 -0
  25. package/dist/core/errors.js.map +1 -1
  26. package/dist/core/index.d.ts +3 -2
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +1 -1
  29. package/dist/core/index.js.map +1 -1
  30. package/dist/core/schema.d.ts +37 -0
  31. package/dist/core/schema.d.ts.map +1 -0
  32. package/dist/core/schema.js +168 -0
  33. package/dist/core/schema.js.map +1 -0
  34. package/dist/core/transform.d.ts +36 -0
  35. package/dist/core/transform.d.ts.map +1 -0
  36. package/dist/core/transform.js +205 -0
  37. package/dist/core/transform.js.map +1 -0
  38. package/dist/core/types.d.ts +104 -1
  39. package/dist/core/types.d.ts.map +1 -1
  40. package/dist/core/types.js.map +1 -1
  41. package/dist/index.d.ts +14 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +9 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/quickbase.esm.js +6 -2
  46. package/dist/quickbase.esm.js.map +4 -4
  47. package/dist/quickbase.min.js +6 -2
  48. package/dist/quickbase.min.js.map +4 -4
  49. package/dist/xml/codepages.d.ts +60 -0
  50. package/dist/xml/codepages.d.ts.map +1 -0
  51. package/dist/xml/codepages.js +88 -0
  52. package/dist/xml/codepages.js.map +1 -0
  53. package/dist/xml/dbvars.d.ts +39 -0
  54. package/dist/xml/dbvars.d.ts.map +1 -0
  55. package/dist/xml/dbvars.js +54 -0
  56. package/dist/xml/dbvars.js.map +1 -0
  57. package/dist/xml/errors.d.ts +64 -0
  58. package/dist/xml/errors.d.ts.map +1 -0
  59. package/dist/xml/errors.js +102 -0
  60. package/dist/xml/errors.js.map +1 -0
  61. package/dist/xml/fields.d.ts +45 -0
  62. package/dist/xml/fields.d.ts.map +1 -0
  63. package/dist/xml/fields.js +86 -0
  64. package/dist/xml/fields.js.map +1 -0
  65. package/dist/xml/groups.d.ts +196 -0
  66. package/dist/xml/groups.d.ts.map +1 -0
  67. package/dist/xml/groups.js +424 -0
  68. package/dist/xml/groups.js.map +1 -0
  69. package/dist/xml/html.d.ts +61 -0
  70. package/dist/xml/html.d.ts.map +1 -0
  71. package/dist/xml/html.js +110 -0
  72. package/dist/xml/html.js.map +1 -0
  73. package/dist/xml/index.d.ts +453 -0
  74. package/dist/xml/index.d.ts.map +1 -0
  75. package/dist/xml/index.js +741 -0
  76. package/dist/xml/index.js.map +1 -0
  77. package/dist/xml/metadata.d.ts +49 -0
  78. package/dist/xml/metadata.d.ts.map +1 -0
  79. package/dist/xml/metadata.js +84 -0
  80. package/dist/xml/metadata.js.map +1 -0
  81. package/dist/xml/records.d.ts +106 -0
  82. package/dist/xml/records.d.ts.map +1 -0
  83. package/dist/xml/records.js +220 -0
  84. package/dist/xml/records.js.map +1 -0
  85. package/dist/xml/request.d.ts +115 -0
  86. package/dist/xml/request.d.ts.map +1 -0
  87. package/dist/xml/request.js +167 -0
  88. package/dist/xml/request.js.map +1 -0
  89. package/dist/xml/roles.d.ts +117 -0
  90. package/dist/xml/roles.d.ts.map +1 -0
  91. package/dist/xml/roles.js +241 -0
  92. package/dist/xml/roles.js.map +1 -0
  93. package/dist/xml/schema.d.ts +94 -0
  94. package/dist/xml/schema.d.ts.map +1 -0
  95. package/dist/xml/schema.js +354 -0
  96. package/dist/xml/schema.js.map +1 -0
  97. package/dist/xml/transform.d.ts +154 -0
  98. package/dist/xml/transform.d.ts.map +1 -0
  99. package/dist/xml/transform.js +182 -0
  100. package/dist/xml/transform.js.map +1 -0
  101. package/dist/xml/types.d.ts +531 -0
  102. package/dist/xml/types.d.ts.map +1 -0
  103. package/dist/xml/types.js +22 -0
  104. package/dist/xml/types.js.map +1 -0
  105. package/dist/xml/users.d.ts +92 -0
  106. package/dist/xml/users.d.ts.map +1 -0
  107. package/dist/xml/users.js +166 -0
  108. package/dist/xml/users.js.map +1 -0
  109. package/dist/xml/webhooks.d.ts +98 -0
  110. package/dist/xml/webhooks.d.ts.map +1 -0
  111. package/dist/xml/webhooks.js +202 -0
  112. package/dist/xml/webhooks.js.map +1 -0
  113. package/package.json +6 -2
package/README.md CHANGED
@@ -5,18 +5,21 @@ A TypeScript/JavaScript client for the QuickBase JSON RESTful API.
5
5
  ## Features
6
6
 
7
7
  - **Typed API Methods** - Full TypeScript support with auto-generated types from OpenAPI spec
8
- - **Multiple Auth Methods** - User token, temporary token, and SSO authentication
8
+ - **Multiple Auth Methods** - User token, temporary token, SSO, and ticket authentication
9
+ - **Schema Aliases** - Use readable names for tables and fields instead of IDs
9
10
  - **Fluent Pagination** - Chain `.all()`, `.paginate()`, or `.noPaginate()` on paginated endpoints
10
11
  - **Date Conversion** - Automatic conversion of ISO date strings to JavaScript Date objects
11
12
  - **Rate Limit Handling** - Automatic retry with exponential backoff on 429 errors
12
13
  - **Proactive Throttling** - Optional client-side request throttling
13
14
  - **Tree-Shakeable** - Static method generation for optimal bundle size
15
+ - **XML API Support** - Access legacy XML-only endpoints (roles, groups, DBVars, code pages, etc.)
16
+ - **Read-Only Mode** - Defense-in-depth protection against accidental writes
14
17
 
15
18
  ## API Reference
16
19
 
17
- **[View all 59 API methods →](./interfaces/QuickbaseAPI.html)**
20
+ **[View Full API Documentation →](https://drewbradfordxyz.github.io/quickbase-js/)**
18
21
 
19
- The SDK provides typed methods for all QuickBase API endpoints including Apps, Tables, Fields, Records, Reports, Users, and more. Each method includes JSDoc documentation with links to the official QuickBase API docs.
22
+ The SDK provides typed methods for all 59 QuickBase API endpoints including Apps, Tables, Fields, Records, Reports, Users, and more. Each method includes JSDoc documentation with links to the official QuickBase API docs.
20
23
 
21
24
  ## Installation
22
25
 
@@ -36,12 +39,12 @@ For QuickBase Code Pages, use temp-token authentication which automatically leve
36
39
  // Realm can be auto-detected from the current URL
37
40
  const realm = window.location.hostname.split('.')[0];
38
41
 
39
- const client = QuickBase.createClient({
42
+ const qb = QuickBase.createClient({
40
43
  realm: realm,
41
44
  auth: { type: 'temp-token' },
42
45
  });
43
46
 
44
- client.getApp({ appId: 'bpqe82s1' }).then(app => {
47
+ qb.getApp({ appId: 'bpqe82s1' }).then(app => {
45
48
  console.log(app.name);
46
49
  });
47
50
  </script>
@@ -53,12 +56,12 @@ Or with ES modules:
53
56
  <script type="module">
54
57
  import { createClient } from 'https://cdn.jsdelivr.net/npm/quickbase-js@2/dist/quickbase.esm.js';
55
58
 
56
- const client = createClient({
59
+ const qb = createClient({
57
60
  realm: 'mycompany',
58
61
  auth: { type: 'temp-token' },
59
62
  });
60
63
 
61
- const app = await client.getApp({ appId: 'bpqe82s1' });
64
+ const app = await qb.getApp({ appId: 'bpqe82s1' });
62
65
  console.log(app.name);
63
66
  </script>
64
67
  ```
@@ -68,7 +71,7 @@ Or with ES modules:
68
71
  ```typescript
69
72
  import { createClient } from 'quickbase-js';
70
73
 
71
- const client = createClient({
74
+ const qb = createClient({
72
75
  realm: 'mycompany',
73
76
  auth: {
74
77
  type: 'user-token',
@@ -77,11 +80,11 @@ const client = createClient({
77
80
  });
78
81
 
79
82
  // Use typed API methods
80
- const app = await client.getApp({ appId: 'bpqe82s1' });
83
+ const app = await qb.getApp({ appId: 'bpqe82s1' });
81
84
  console.log(app.name);
82
85
 
83
86
  // Query records
84
- const records = await client.runQuery({
87
+ const records = await qb.runQuery({
85
88
  from: 'bpqe82s1',
86
89
  select: [3, 6, 7],
87
90
  where: "{6.EX.'Active'}",
@@ -94,7 +97,7 @@ console.log(records.data);
94
97
  ### User Token (Recommended for Server-Side)
95
98
 
96
99
  ```typescript
97
- const client = createClient({
100
+ const qb = createClient({
98
101
  realm: 'mycompany',
99
102
  auth: {
100
103
  type: 'user-token',
@@ -105,26 +108,16 @@ const client = createClient({
105
108
 
106
109
  ### Temporary Token (Recommended for Browser/Code Pages)
107
110
 
108
- Temporary tokens are automatically fetched and cached per database. They refresh on 401 errors.
111
+ Temporary tokens are automatically fetched and cached per database. Tokens are refreshed proactively before expiration (default: 4m 50s TTL for 5-minute tokens). The SDK also handles unexpected 401 errors as a fallback.
109
112
 
110
113
  ```typescript
111
- // In QuickBase Code Pages - no user token needed (uses browser session)
112
- const client = createClient({
114
+ // In QuickBase Code Pages - uses browser session for authentication
115
+ const qb = createClient({
113
116
  realm: 'mycompany',
114
117
  auth: {
115
118
  type: 'temp-token',
116
119
  },
117
120
  });
118
-
119
- // Outside Code Pages - user token required to fetch temp tokens
120
- const client = createClient({
121
- realm: 'mycompany',
122
- auth: {
123
- type: 'temp-token',
124
- userToken: 'your-user-token', // Required outside Code Pages
125
- appToken: 'optional-app-token', // If app requires it
126
- },
127
- });
128
121
  ```
129
122
 
130
123
  **Auto dbid extraction:** The SDK automatically extracts the database ID for temp token authentication from your request parameters:
@@ -140,7 +133,7 @@ This means you don't need to manually specify the dbid - just make API calls and
140
133
  ### SSO Token
141
134
 
142
135
  ```typescript
143
- const client = createClient({
136
+ const qb = createClient({
144
137
  realm: 'mycompany',
145
138
  auth: {
146
139
  type: 'sso',
@@ -149,6 +142,36 @@ const client = createClient({
149
142
  });
150
143
  ```
151
144
 
145
+ ### Ticket Auth (Username/Password)
146
+
147
+ Ticket authentication lets users log in with their QuickBase email and password. Unlike user tokens, tickets properly attribute record changes (`createdBy`/`modifiedBy`) to the authenticated user. Credentials should come from user input (e.g., a login form), not hardcoded.
148
+
149
+ ```typescript
150
+ // Create client after user submits login form
151
+ function handleLogin(formData: { username: string; password: string }) {
152
+ const qb = createClient({
153
+ realm: 'mycompany',
154
+ auth: {
155
+ type: 'ticket',
156
+ username: formData.username,
157
+ password: formData.password,
158
+ hours: 24, // Optional: validity (default: 12h, max: ~6 months)
159
+ persist: 'sessionStorage', // Optional: survive page refresh
160
+ onExpired: () => { // Optional: called when ticket expires
161
+ window.location.href = '/login';
162
+ },
163
+ },
164
+ });
165
+ return qb;
166
+ }
167
+ ```
168
+
169
+ **Key behaviors:**
170
+ - Authentication happens lazily on the first API call
171
+ - Password is discarded from memory immediately after authentication
172
+ - Use `persist` to survive page refreshes (`'sessionStorage'` or `'localStorage'`)
173
+ - Use `onExpired` to handle session expiration (e.g., redirect to login)
174
+
152
175
  ## Pagination
153
176
 
154
177
  Paginated endpoints (`runQuery`, `runReport`, `getUsers`, etc.) return a `PaginatedRequest` that supports fluent pagination methods.
@@ -157,7 +180,7 @@ Paginated endpoints (`runQuery`, `runReport`, `getUsers`, etc.) return a `Pagina
157
180
 
158
181
  ```typescript
159
182
  // Returns first page only
160
- const page = await client.runQuery({ from: 'tableId' });
183
+ const page = await qb.runQuery({ from: 'tableId' });
161
184
  console.log(page.data.length); // e.g., 100 records
162
185
  console.log(page.metadata.totalRecords); // e.g., 5000 total
163
186
  ```
@@ -166,7 +189,7 @@ console.log(page.metadata.totalRecords); // e.g., 5000 total
166
189
 
167
190
  ```typescript
168
191
  // Automatically fetches all pages and combines results
169
- const all = await client.runQuery({ from: 'tableId' }).all();
192
+ const all = await qb.runQuery({ from: 'tableId' }).all();
170
193
  console.log(all.data.length); // 5000 records
171
194
  ```
172
195
 
@@ -174,7 +197,7 @@ console.log(all.data.length); // 5000 records
174
197
 
175
198
  ```typescript
176
199
  // Fetch up to 500 records (may span multiple pages)
177
- const limited = await client.runQuery({ from: 'tableId' }).paginate({ limit: 500 });
200
+ const limited = await qb.runQuery({ from: 'tableId' }).paginate({ limit: 500 });
178
201
  console.log(limited.data.length); // 500 records
179
202
  ```
180
203
 
@@ -182,23 +205,23 @@ console.log(limited.data.length); // 500 records
182
205
 
183
206
  ```typescript
184
207
  // Explicitly fetch single page (useful when autoPaginate is enabled globally)
185
- const single = await client.runQuery({ from: 'tableId' }).noPaginate();
208
+ const single = await qb.runQuery({ from: 'tableId' }).noPaginate();
186
209
  ```
187
210
 
188
211
  ### Global Auto-Pagination
189
212
 
190
213
  ```typescript
191
- const client = createClient({
214
+ const qb = createClient({
192
215
  realm: 'mycompany',
193
216
  auth: { type: 'user-token', userToken: 'token' },
194
217
  autoPaginate: true, // Default await now fetches all pages
195
218
  });
196
219
 
197
220
  // Now direct await fetches all pages
198
- const all = await client.runQuery({ from: 'tableId' });
221
+ const all = await qb.runQuery({ from: 'tableId' });
199
222
 
200
223
  // Use .noPaginate() to get single page
201
- const page = await client.runQuery({ from: 'tableId' }).noPaginate();
224
+ const page = await qb.runQuery({ from: 'tableId' }).noPaginate();
202
225
  ```
203
226
 
204
227
  ## Date Conversion
@@ -206,14 +229,14 @@ const page = await client.runQuery({ from: 'tableId' }).noPaginate();
206
229
  By default, ISO 8601 date strings in API responses are automatically converted to JavaScript `Date` objects:
207
230
 
208
231
  ```typescript
209
- const app = await client.getApp({ appId: 'bpqe82s1' });
232
+ const app = await qb.getApp({ appId: 'bpqe82s1' });
210
233
 
211
234
  // Date fields are now Date objects
212
235
  console.log(app.created instanceof Date); // true
213
236
  console.log(app.created.toISOString()); // "2024-01-15T10:30:00.000Z"
214
237
 
215
238
  // Works with nested objects and arrays
216
- const records = await client.runQuery({ from: 'tableId' });
239
+ const records = await qb.runQuery({ from: 'tableId' });
217
240
  records.data.forEach(record => {
218
241
  // Date field values are converted
219
242
  console.log(record['7'].value instanceof Date); // true for date/timestamp fields
@@ -223,17 +246,221 @@ records.data.forEach(record => {
223
246
  To disable date conversion and keep dates as strings:
224
247
 
225
248
  ```typescript
226
- const client = createClient({
249
+ const qb = createClient({
227
250
  realm: 'mycompany',
228
251
  auth: { type: 'user-token', userToken: 'token' },
229
252
  convertDates: false, // Keep dates as ISO strings
230
253
  });
231
254
  ```
232
255
 
256
+ ## Schema Aliases
257
+
258
+ Use readable names for tables and fields instead of cryptic IDs. The SDK transforms aliases to IDs in requests and IDs back to aliases in responses.
259
+
260
+ ### Defining a Schema
261
+
262
+ ```typescript
263
+ const qb = createClient({
264
+ realm: 'mycompany',
265
+ auth: { type: 'user-token', userToken: 'token' },
266
+ schema: {
267
+ tables: {
268
+ projects: {
269
+ id: 'bqw3ryzab',
270
+ fields: {
271
+ id: 3,
272
+ name: 6,
273
+ status: 7,
274
+ dueDate: 12,
275
+ assignee: 15,
276
+ },
277
+ },
278
+ tasks: {
279
+ id: 'bqw4xyzcd',
280
+ fields: {
281
+ id: 3,
282
+ title: 6,
283
+ projectId: 8,
284
+ completed: 10,
285
+ },
286
+ },
287
+ },
288
+ },
289
+ });
290
+ ```
291
+
292
+ ### Using Aliases in Queries
293
+
294
+ ```typescript
295
+ // Use table and field aliases instead of IDs
296
+ const result = await qb.runQuery({
297
+ from: 'projects', // Instead of 'bqw3ryzab'
298
+ select: ['name', 'status', 'dueDate'], // Instead of [6, 7, 12]
299
+ where: "{'status'.EX.'Active'}", // Field aliases in where clauses
300
+ sortBy: [{ fieldId: 'dueDate', order: 'ASC' }],
301
+ });
302
+
303
+ // Response uses aliases and values are automatically unwrapped
304
+ console.log(result.data[0].name); // "Project Alpha" (not { value: "Project Alpha" })
305
+ console.log(result.data[0].status); // "Active"
306
+ console.log(result.data[0].dueDate); // Date object (if convertDates is enabled)
307
+ ```
308
+
309
+ ### Upserting with Aliases
310
+
311
+ With schema, use field aliases as keys:
312
+
313
+ ```typescript
314
+ await qb.upsert({
315
+ to: 'projects',
316
+ data: [
317
+ { name: 'New Project', status: 'Planning' },
318
+ ],
319
+ });
320
+ ```
321
+
322
+ ## Auto-Wrapping
323
+
324
+ Values are automatically wrapped in `{ value: X }` format for upserts. This works with or without a schema:
325
+
326
+ ```typescript
327
+ // Clean syntax - values are auto-wrapped
328
+ { '6': 'New Record', '7': 42 }
329
+
330
+ // Explicit wrapping also works (backwards compatible)
331
+ { '6': { value: 'New Record' }, '7': { value: 42 } }
332
+ ```
333
+
334
+ ### Response Transformation
335
+
336
+ Responses are automatically transformed:
337
+ - Field ID keys (`'6'`) become aliases (`name`)
338
+ - Values are unwrapped from `{ value: X }` to just `X`
339
+ - Unknown fields (not in schema) keep their numeric key but are still unwrapped
340
+
341
+ ```typescript
342
+ // Raw API response:
343
+ { data: [{ '6': { value: 'Alpha' }, '99': { value: 'Custom' } }] }
344
+
345
+ // Transformed response (with schema):
346
+ { data: [{ name: 'Alpha', '99': 'Custom' }] }
347
+ ```
348
+
349
+ ### Helpful Error Messages
350
+
351
+ Typos in aliases throw errors with suggestions:
352
+
353
+ ```typescript
354
+ // Throws: Unknown field alias 'stauts' in table 'projects'. Did you mean 'status'?
355
+ await qb.runQuery({
356
+ from: 'projects',
357
+ select: ['stauts'], // Typo!
358
+ });
359
+ ```
360
+
361
+ ### Generating Schema from QuickBase
362
+
363
+ Use the CLI to generate a schema from an existing app:
364
+
365
+ ```bash
366
+ # Generate TypeScript schema file
367
+ npx quickbase-js schema -r "$QB_REALM" -a "$QB_APP_ID" -t "$QB_USER_TOKEN" -o schema.ts
368
+
369
+ # Generate JSON format
370
+ npx quickbase-js schema -r "$QB_REALM" -a "$QB_APP_ID" -t "$QB_USER_TOKEN" -f json -o schema.json
371
+
372
+ # Output to stdout
373
+ npx quickbase-js schema -r "$QB_REALM" -a "$QB_APP_ID" -t "$QB_USER_TOKEN"
374
+ ```
375
+
376
+ The generator creates aliases from field labels using camelCase (e.g., "Due Date" → `dueDate`).
377
+
378
+ ### Updating Schema with --merge
379
+
380
+ When your QuickBase app changes (new tables, new fields), use `--merge` to update your schema while preserving any custom aliases you've set:
381
+
382
+ ```bash
383
+ # Update schema, preserving custom aliases
384
+ npx quickbase-js schema -r "$QB_REALM" -a "$QB_APP_ID" -t "$QB_USER_TOKEN" -o schema.ts --merge
385
+ ```
386
+
387
+ **What merge does:**
388
+ - Preserves your custom table and field aliases (matched by ID, not name)
389
+ - Adds new tables and fields with auto-generated aliases
390
+ - Reports what changed:
391
+
392
+ ```
393
+ Merge complete:
394
+ Tables: 2 preserved, 1 added, 0 removed
395
+ Fields: 15 preserved, 3 added, 0 removed
396
+ ```
397
+
398
+ This lets you rename auto-generated aliases like `dateCreated` to `created` and keep them through updates.
399
+
400
+ ### Using a Separate Schema File
401
+
402
+ You can define your schema in a separate file and import it:
403
+
404
+ **schema.ts**
405
+ ```typescript
406
+ import type { Schema } from 'quickbase-js';
407
+
408
+ export const schema: Schema = {
409
+ tables: {
410
+ projects: {
411
+ id: 'bqw3ryzab',
412
+ fields: {
413
+ id: 3,
414
+ name: 6,
415
+ status: 7,
416
+ dueDate: 12,
417
+ },
418
+ },
419
+ },
420
+ };
421
+ ```
422
+
423
+ **client.ts**
424
+ ```typescript
425
+ import { createClient } from 'quickbase-js';
426
+ import { schema } from './schema.js';
427
+
428
+ const qb = createClient({
429
+ realm: 'mycompany',
430
+ auth: { type: 'user-token', userToken: 'token' },
431
+ schema,
432
+ });
433
+ ```
434
+
435
+ Or use a JSON file:
436
+
437
+ **schema.json**
438
+ ```json
439
+ {
440
+ "tables": {
441
+ "projects": {
442
+ "id": "bqw3ryzab",
443
+ "fields": { "id": 3, "name": 6, "status": 7 }
444
+ }
445
+ }
446
+ }
447
+ ```
448
+
449
+ ```typescript
450
+ import { createClient } from 'quickbase-js';
451
+ import schema from './schema.json' assert { type: 'json' };
452
+
453
+ const qb = createClient({
454
+ realm: 'mycompany',
455
+ auth: { type: 'user-token', userToken: 'token' },
456
+ schema,
457
+ });
458
+ ```
459
+
233
460
  ## Configuration Options
234
461
 
235
462
  ```typescript
236
- const client = createClient({
463
+ const qb = createClient({
237
464
  // Required
238
465
  realm: 'mycompany',
239
466
  auth: { /* see authentication section */ },
@@ -244,6 +471,8 @@ const client = createClient({
244
471
  baseUrl: 'https://api.quickbase.com/v1', // API base URL
245
472
  autoPaginate: false, // Auto-paginate on direct await
246
473
  convertDates: true, // Convert ISO date strings to Date objects
474
+ readOnly: false, // Block all write operations
475
+ schema: { /* see schema aliases section */ }, // Table and field aliases
247
476
 
248
477
  // Rate limiting
249
478
  rateLimit: {
@@ -272,26 +501,327 @@ const client = createClient({
272
501
  });
273
502
  ```
274
503
 
504
+ ## XML API (Legacy Endpoints)
505
+
506
+ The XML API provides access to endpoints not available in the JSON API, including role management, group management, app variables, code pages, and more.
507
+
508
+ > **Note:** The XML API is a legacy API that may be discontinued by QuickBase in the future. Use the JSON API when possible.
509
+
510
+ ### Quick Start
511
+
512
+ ```typescript
513
+ import { createClient, createXmlClient } from 'quickbase-js';
514
+
515
+ // Create main client
516
+ const qb = createClient({
517
+ realm: 'mycompany',
518
+ auth: { type: 'user-token', userToken: 'your-token' },
519
+ });
520
+
521
+ // Create XML client from main client
522
+ const xml = createXmlClient(qb);
523
+
524
+ // Get all roles in an app
525
+ const roles = await xml.getRoleInfo(appId);
526
+ for (const role of roles.roles) {
527
+ console.log(`${role.name}: ${role.access.description}`);
528
+ }
529
+
530
+ // Get all users and their role assignments
531
+ const users = await xml.userRoles(appId);
532
+ for (const user of users.users) {
533
+ console.log(`${user.name}: ${user.roles.map(r => r.name).join(', ')}`);
534
+ }
535
+
536
+ // Get comprehensive schema (fields, reports, variables)
537
+ const schema = await xml.getSchema(tableId);
538
+ for (const field of schema.table.fields ?? []) {
539
+ console.log(`Field ${field.id}: ${field.label} (${field.fieldType})`);
540
+ }
541
+ ```
542
+
543
+ ### Available Methods
544
+
545
+ **App Discovery:**
546
+
547
+ | Method | XML Action | Description |
548
+ |--------|------------|-------------|
549
+ | `grantedDBs(opts?)` | API_GrantedDBs | List all apps/tables user can access |
550
+ | `findDBByName(name, parentsOnly?)` | API_FindDBByName | Find an app by name |
551
+ | `getDBInfo(dbid)` | API_GetDBInfo | Get app/table metadata (record count, manager, timestamps) |
552
+ | `getNumRecords(tableId)` | API_GetNumRecords | Get total record count for a table |
553
+
554
+ **Role Management:**
555
+
556
+ | Method | XML Action | Description |
557
+ |--------|------------|-------------|
558
+ | `getRoleInfo(appId)` | API_GetRoleInfo | Get all roles defined in an application |
559
+ | `userRoles(appId)` | API_UserRoles | Get all users and their role assignments |
560
+ | `getUserRole(appId, userId, includeGroups?)` | API_GetUserRole | Get roles for a specific user |
561
+ | `addUserToRole(appId, userId, roleId)` | API_AddUserToRole | Assign a user to a role |
562
+ | `removeUserFromRole(appId, userId, roleId)` | API_RemoveUserFromRole | Remove a user from a role |
563
+ | `changeUserRole(appId, userId, currentRole, newRole)` | API_ChangeUserRole | Change a user's role |
564
+
565
+ **User Management:**
566
+
567
+ | Method | XML Action | Description |
568
+ |--------|------------|-------------|
569
+ | `getUserInfo(email)` | API_GetUserInfo | Get user info by email address |
570
+ | `provisionUser(appId, email, opts?)` | API_ProvisionUser | Create a new unregistered user |
571
+ | `sendInvitation(appId, userId, userText?)` | API_SendInvitation | Send invitation email to a user |
572
+ | `changeManager(appId, newManagerEmail)` | API_ChangeManager | Change the app manager |
573
+ | `changeRecordOwner(tableId, recordId, newOwner)` | API_ChangeRecordOwner | Change record owner |
574
+
575
+ **Group Management:**
576
+
577
+ | Method | XML Action | Description |
578
+ |--------|------------|-------------|
579
+ | `createGroup(name, opts?)` | API_CreateGroup | Create a new group |
580
+ | `deleteGroup(groupId)` | API_DeleteGroup | Delete a group |
581
+ | `copyGroup(groupId, name, opts?)` | API_CopyGroup | Copy a group |
582
+ | `changeGroupInfo(groupId, opts)` | API_ChangeGroupInfo | Update group name/description |
583
+ | `getUsersInGroup(groupId, includeManagers?)` | API_GetUsersInGroup | Get users and managers in a group |
584
+ | `addUserToGroup(groupId, userId, allowAdmin?)` | API_AddUserToGroup | Add a user to a group |
585
+ | `removeUserFromGroup(groupId, userId)` | API_RemoveUserFromGroup | Remove a user from a group |
586
+ | `getGroupRole(appId, groupId)` | API_GetGroupRole | Get roles assigned to a group |
587
+ | `addGroupToRole(appId, groupId, roleId)` | API_AddGroupToRole | Assign a group to a role |
588
+ | `removeGroupFromRole(appId, groupId, roleId, allRoles?)` | API_RemoveGroupFromRole | Remove a group from a role |
589
+ | `grantedGroups(userId?, adminOnly?)` | API_GrantedGroups | Get groups a user belongs to |
590
+ | `grantedDBsForGroup(groupId)` | API_GrantedDBsForGroup | Get apps a group can access |
591
+
592
+ **App Metadata:**
593
+
594
+ | Method | XML Action | Description |
595
+ |--------|------------|-------------|
596
+ | `getAppDTMInfo(appId)` | API_GetAppDTMInfo | Get modification timestamps |
597
+ | `getAncestorInfo(appId)` | API_GetAncestorInfo | Get app copy/template lineage info |
598
+
599
+ **Application Variables:**
600
+
601
+ | Method | XML Action | Description |
602
+ |--------|------------|-------------|
603
+ | `getDBVar(appId, varName)` | API_GetDBVar | Get an application variable value |
604
+ | `setDBVar(appId, varName, value)` | API_SetDBVar | Set an application variable value |
605
+
606
+ **Code Pages:**
607
+
608
+ | Method | XML Action | Description |
609
+ |--------|------------|-------------|
610
+ | `getDBPage(appId, pageIdOrName)` | API_GetDBPage | Get stored code page content |
611
+ | `addReplaceDBPage(appId, opts)` | API_AddReplaceDBPage | Create or update a code page |
612
+
613
+ **Field Management:**
614
+
615
+ | Method | XML Action | Description |
616
+ |--------|------------|-------------|
617
+ | `fieldAddChoices(tableId, fieldId, choices)` | API_FieldAddChoices | Add choices to a multiple-choice field |
618
+ | `fieldRemoveChoices(tableId, fieldId, choices)` | API_FieldRemoveChoices | Remove choices from a field |
619
+ | `setKeyField(tableId, fieldId)` | API_SetKeyField | Set the key field for a table |
620
+
621
+ **Schema Information:**
622
+
623
+ | Method | XML Action | Description |
624
+ |--------|------------|-------------|
625
+ | `getSchema(dbid)` | API_GetSchema | Get comprehensive app/table metadata |
626
+
627
+ **Record Information:**
628
+
629
+ | Method | XML Action | Description |
630
+ |--------|------------|-------------|
631
+ | `doQueryCount(tableId, query?)` | API_DoQueryCount | Get count of matching records (no data fetch) |
632
+ | `getRecordInfo(tableId, recordId)` | API_GetRecordInfo | Get record with field metadata |
633
+ | `getRecordInfoByKey(tableId, keyValue)` | API_GetRecordInfo | Get record by key field value |
634
+
635
+ **Record Operations:**
636
+
637
+ | Method | XML Action | Description |
638
+ |--------|------------|-------------|
639
+ | `copyMasterDetail(tableId, opts)` | API_CopyMasterDetail | Copy a master record with its detail records |
640
+ | `importFromCSV(tableId, opts)` | API_ImportFromCSV | Bulk import/update records from CSV data |
641
+ | `runImport(tableId, importId)` | API_RunImport | Execute a saved import definition |
642
+
643
+ **Webhooks:**
644
+
645
+ | Method | XML Action | Description |
646
+ |--------|------------|-------------|
647
+ | `webhooksCreate(tableId, opts)` | API_Webhooks_Create | Create a webhook |
648
+ | `webhooksEdit(tableId, webhookId, opts)` | API_Webhooks_Edit | Edit a webhook |
649
+ | `webhooksDelete(tableId, webhookId)` | API_Webhooks_Delete | Delete a webhook |
650
+ | `webhooksActivate(tableId, webhookId)` | API_Webhooks_Activate | Activate a webhook |
651
+ | `webhooksDeactivate(tableId, webhookId)` | API_Webhooks_Deactivate | Deactivate a webhook |
652
+ | `webhooksCopy(tableId, webhookId, name?)` | API_Webhooks_Copy | Copy a webhook |
653
+
654
+ **HTML Generation:**
655
+
656
+ | Method | XML Action | Description |
657
+ |--------|------------|-------------|
658
+ | `genAddRecordForm(tableId, fields?)` | API_GenAddRecordForm | Generate HTML form for adding a record |
659
+ | `genResultsTable(tableId, opts?)` | API_GenResultsTable | Generate HTML/JS/CSV table of query results |
660
+ | `getRecordAsHTML(tableId, opts)` | API_GetRecordAsHTML | Get a record rendered as HTML |
661
+
662
+ **Authentication:**
663
+
664
+ | Method | XML Action | Description |
665
+ |--------|------------|-------------|
666
+ | `signOut()` | API_SignOut | Clear ticket cookie (browser-focused) |
667
+
668
+ ### Error Handling
669
+
670
+ XML API errors are returned as `XmlError`:
671
+
672
+ ```typescript
673
+ import { XmlError, isUnauthorized, isNotFound, isInvalidTicket } from 'quickbase-js';
674
+
675
+ try {
676
+ const roles = await xml.getRoleInfo(appId);
677
+ } catch (error) {
678
+ if (error instanceof XmlError) {
679
+ console.log(`XML API error ${error.code}: ${error.text}`);
680
+
681
+ // Use helper functions for common error types
682
+ if (isUnauthorized(error)) {
683
+ console.log('Not authorized');
684
+ } else if (isNotFound(error)) {
685
+ console.log('Resource not found');
686
+ } else if (isInvalidTicket(error)) {
687
+ console.log('Invalid or expired ticket');
688
+ }
689
+ }
690
+ }
691
+ ```
692
+
693
+ ### Read-Only Mode
694
+
695
+ The XML client inherits read-only mode from the main client (see [Read-Only Mode](#read-only-mode-1)):
696
+
697
+ ```typescript
698
+ const qb = createClient({
699
+ realm: 'mycompany',
700
+ auth: { type: 'user-token', userToken: 'token' },
701
+ readOnly: true,
702
+ });
703
+
704
+ const xml = createXmlClient(qb); // Inherits readOnly: true
705
+
706
+ // Read operations work
707
+ const roles = await xml.getRoleInfo(appId);
708
+
709
+ // Write operations throw ReadOnlyError
710
+ await xml.addUserToRole(appId, userId, roleId); // Throws!
711
+ ```
712
+
713
+ You can also override read-only mode for just the XML client:
714
+
715
+ ```typescript
716
+ const xml = createXmlClient(qb, { readOnly: true });
717
+ ```
718
+
719
+ ### Schema Aliases
720
+
721
+ The XML client inherits the schema from the main client and supports table and field aliases:
722
+
723
+ ```typescript
724
+ const qb = createClient({
725
+ realm: 'mycompany',
726
+ auth: { type: 'user-token', userToken: 'token' },
727
+ schema: {
728
+ tables: {
729
+ projects: {
730
+ id: 'bqprojects123',
731
+ fields: {
732
+ id: 3,
733
+ name: 6,
734
+ status: 7,
735
+ },
736
+ },
737
+ },
738
+ },
739
+ });
740
+
741
+ const xml = createXmlClient(qb);
742
+
743
+ // Use table aliases
744
+ const count = await xml.getNumRecords('projects'); // Resolves to 'bqprojects123'
745
+ const roles = await xml.getRoleInfo('projects');
746
+
747
+ // Use field aliases
748
+ await xml.fieldAddChoices('projects', 'status', ['Active', 'Inactive']);
749
+ await xml.setKeyField('projects', 'name');
750
+
751
+ // Field aliases in importFromCSV
752
+ await xml.importFromCSV('projects', {
753
+ recordsCsv: 'Project A,Active',
754
+ clist: ['name', 'status'],
755
+ mergeFieldId: 'id',
756
+ });
757
+ ```
758
+
759
+ **Response Transformation:**
760
+
761
+ The XML client transforms responses to use field and table aliases as keys when a schema is configured, similar to the JSON API:
762
+
763
+ ```typescript
764
+ // getRecordInfo - access fields by alias
765
+ const record = await xml.getRecordInfo('projects', 123);
766
+ console.log(record.fields.name.value); // "Project Alpha"
767
+ console.log(record.fields.status.value); // "Active"
768
+
769
+ // Without schema, fields are keyed by ID
770
+ const record2 = await xmlNoSchema.getRecordInfo('bqprojects123', 123);
771
+ console.log(record2.fields['6'].value); // "Project Alpha"
772
+
773
+ // getSchema - access fields by alias
774
+ const schema = await xml.getSchema('projects');
775
+ console.log(schema.table.fields?.name.label); // "Project Name"
776
+ console.log(schema.table.fields?.status.label); // "Status"
777
+ console.log(schema.table.childTables?.tasks.dbid); // "bqtasks123"
778
+
779
+ // grantedDBs - access databases by alias
780
+ const dbs = await xml.grantedDBs();
781
+ console.log(dbs.databases.projects.dbname); // "My Projects App"
782
+ console.log(dbs.databases.tasks.dbname); // "Tasks Table"
783
+
784
+ // Unknown tables/fields are keyed by their ID
785
+ console.log(dbs.databases['bqunknown123'].dbname); // "Unknown Table"
786
+
787
+ // getAppDTMInfo - access tables by alias
788
+ const dtm = await xml.getAppDTMInfo('projects');
789
+ console.log(dtm.appAlias); // "projects"
790
+ console.log(dtm.tables.projects.lastModifiedTime); // "1234567890"
791
+ console.log(dtm.tables.tasks.lastModifiedTime); // "1234567891"
792
+ ```
793
+
794
+ **Responses transformed to keyed objects:**
795
+
796
+ | Method | Keyed Properties |
797
+ |--------|-----------------|
798
+ | `getRecordInfo` / `getRecordInfoByKey` | `fields.{alias}` |
799
+ | `getSchema` | `table.fields.{alias}`, `table.childTables.{alias}` |
800
+ | `grantedDBs` / `grantedDBsForGroup` | `databases.{alias}` |
801
+ | `getAppDTMInfo` | `tables.{alias}` |
802
+ | `fieldAddChoices` / `fieldRemoveChoices` | `fieldAlias` property |
803
+ | `findDBByName` | `alias` property |
804
+
275
805
  ## API Methods
276
806
 
277
807
  All QuickBase API endpoints are available as typed methods:
278
808
 
279
809
  ```typescript
280
810
  // Apps
281
- const app = await client.getApp({ appId: 'bpqe82s1' });
282
- const newApp = await client.createApp({ name: 'My App', description: 'Test' });
283
- await client.updateApp({ appId: 'bpqe82s1' }, { name: 'Updated Name' });
284
- await client.deleteApp({ appId: 'bpqe82s1' }, { name: 'My App' });
811
+ const app = await qb.getApp({ appId: 'bpqe82s1' });
812
+ const newApp = await qb.createApp({ name: 'My App', description: 'Test' });
813
+ await qb.updateApp({ appId: 'bpqe82s1' }, { name: 'Updated Name' });
814
+ await qb.deleteApp({ appId: 'bpqe82s1' }, { name: 'My App' });
285
815
 
286
816
  // Tables
287
- const tables = await client.getAppTables({ appId: 'bpqe82s1' });
288
- const table = await client.getTable({ appId: 'bpqe82s1', tableId: 'byyy82s1' });
817
+ const tables = await qb.getAppTables({ appId: 'bpqe82s1' });
818
+ const table = await qb.getTable({ appId: 'bpqe82s1', tableId: 'byyy82s1' });
289
819
 
290
820
  // Fields
291
- const fields = await client.getFields({ tableId: 'byyy82s1' });
821
+ const fields = await qb.getFields({ tableId: 'byyy82s1' });
292
822
 
293
823
  // Records
294
- const records = await client.runQuery({
824
+ const records = await qb.runQuery({
295
825
  from: 'byyy82s1',
296
826
  select: [3, 6, 7],
297
827
  where: "{6.GT.100}",
@@ -299,28 +829,28 @@ const records = await client.runQuery({
299
829
  options: { top: 100, skip: 0 },
300
830
  });
301
831
 
302
- // Insert/Update records
303
- const result = await client.upsert({
832
+ // Insert/Update records (values are auto-wrapped)
833
+ const result = await qb.upsert({
304
834
  to: 'byyy82s1',
305
835
  data: [
306
- { '6': { value: 'New Record' }, '7': { value: 42 } },
836
+ { '6': 'New Record', '7': 42 },
307
837
  ],
308
838
  });
309
839
 
310
840
  // Delete records
311
- await client.deleteRecords({
841
+ await qb.deleteRecords({
312
842
  from: 'byyy82s1',
313
843
  where: "{3.EX.123}",
314
844
  });
315
845
 
316
846
  // Reports
317
- const report = await client.runReport(
847
+ const report = await qb.runReport(
318
848
  { reportId: 'abc123', tableId: 'byyy82s1' },
319
849
  { skip: 0, top: 100 }
320
850
  );
321
851
 
322
852
  // Users
323
- const users = await client.getUsers(
853
+ const users = await qb.getUsers(
324
854
  { accountId: '123456' },
325
855
  { appIds: ['bpqe82s1'] }
326
856
  );
@@ -331,7 +861,7 @@ const users = await client.getUsers(
331
861
  For endpoints not covered by typed methods, use the generic `request` method:
332
862
 
333
863
  ```typescript
334
- const result = await client.request<MyResponseType>({
864
+ const result = await qb.request<MyResponseType>({
335
865
  method: 'POST',
336
866
  path: '/some/endpoint',
337
867
  body: { key: 'value' },
@@ -339,6 +869,99 @@ const result = await client.request<MyResponseType>({
339
869
  });
340
870
  ```
341
871
 
872
+ ## Read-Only Mode
873
+
874
+ Enable read-only mode to prevent accidental writes during development, testing, or when building read-only dashboards. This feature uses defense-in-depth with two layers of protection.
875
+
876
+ ```typescript
877
+ import { createClient, createXmlClient, ReadOnlyError } from 'quickbase-js';
878
+
879
+ const qb = createClient({
880
+ realm: 'mycompany',
881
+ auth: { type: 'user-token', userToken: 'token' },
882
+ readOnly: true,
883
+ });
884
+
885
+ // Read operations work normally
886
+ const app = await qb.getApp({ appId: 'bpqe82s1' });
887
+ const records = await qb.runQuery({ from: 'byyy82s1' });
888
+
889
+ // Write operations throw ReadOnlyError
890
+ try {
891
+ await qb.upsert({ to: 'byyy82s1', data: [{ '6': 'test' }] });
892
+ } catch (error) {
893
+ if (error instanceof ReadOnlyError) {
894
+ console.log(`Blocked: ${error.method} ${error.path}`);
895
+ // "Blocked: POST /records"
896
+ }
897
+ }
898
+ ```
899
+
900
+ ### What Gets Blocked
901
+
902
+ | Operation Type | Examples | Blocked? |
903
+ |----------------|----------|----------|
904
+ | **GET requests** | getApp, getFields, getTables | ✅ Allowed |
905
+ | **Query POSTs** | runQuery, runReport, getUsers | ✅ Allowed |
906
+ | **Create/Update** | upsert, createApp, createTable | ❌ Blocked |
907
+ | **Delete** | deleteRecords, deleteApp | ❌ Blocked |
908
+ | **XML writes** | addUserToRole, setDBVar | ❌ Blocked |
909
+
910
+ ### Defense-in-Depth
911
+
912
+ The read-only check uses two layers:
913
+
914
+ 1. **Explicit blocklist** - Known write endpoints are blocked by path matching
915
+ 2. **HTTP method check** - Any POST/PUT/DELETE/PATCH not in the allowlist is blocked
916
+
917
+ This ensures new API endpoints are blocked by default until explicitly allowlisted.
918
+
919
+ ### XML API
920
+
921
+ The XML client automatically inherits read-only mode from the main client:
922
+
923
+ ```typescript
924
+ const qb = createClient({
925
+ realm: 'mycompany',
926
+ auth: { type: 'user-token', userToken: 'token' },
927
+ readOnly: true,
928
+ });
929
+
930
+ const xml = createXmlClient(qb); // Inherits readOnly: true
931
+
932
+ // Read operations work
933
+ const roles = await xml.getRoleInfo(appId);
934
+
935
+ // Write operations throw ReadOnlyError with action info
936
+ try {
937
+ await xml.setDBVar(appId, 'myVar', 'value');
938
+ } catch (error) {
939
+ if (error instanceof ReadOnlyError) {
940
+ console.log(`Blocked XML action: ${error.action}`);
941
+ // "Blocked XML action: API_SetDBVar"
942
+ }
943
+ }
944
+ ```
945
+
946
+ ### ReadOnlyError
947
+
948
+ The `ReadOnlyError` class provides details about the blocked operation:
949
+
950
+ ```typescript
951
+ import { ReadOnlyError } from 'quickbase-js';
952
+
953
+ try {
954
+ await qb.deleteRecords({ from: 'byyy82s1', where: '{3.GT.0}' });
955
+ } catch (error) {
956
+ if (error instanceof ReadOnlyError) {
957
+ error.method; // "DELETE"
958
+ error.path; // "/records"
959
+ error.action; // undefined (only set for XML API)
960
+ error.message; // "Read-only mode: write operation blocked (DELETE /records)"
961
+ }
962
+ }
963
+ ```
964
+
342
965
  ## Error Handling
343
966
 
344
967
  ```typescript
@@ -350,10 +973,11 @@ import {
350
973
  NotFoundError,
351
974
  ValidationError,
352
975
  ServerError,
976
+ ReadOnlyError,
353
977
  } from 'quickbase-js';
354
978
 
355
979
  try {
356
- await client.getApp({ appId: 'invalid' });
980
+ await qb.getApp({ appId: 'invalid' });
357
981
  } catch (error) {
358
982
  if (error instanceof RateLimitError) {
359
983
  console.log(`Rate limited. Retry after ${error.retryAfter} seconds`);
@@ -374,6 +998,108 @@ try {
374
998
  }
375
999
  ```
376
1000
 
1001
+ ## Rate Limiting
1002
+
1003
+ QuickBase enforces a rate limit of **100 requests per 10 seconds** per user token. This SDK follows [QuickBase's official rate limiting guidance](https://developer.quickbase.com/rateLimit) — relying on server-side `Retry-After` headers by default, with optional client-side throttling.
1004
+
1005
+ ### How 429 Errors Are Handled
1006
+
1007
+ When the SDK receives a 429 (Too Many Requests) response, it automatically:
1008
+
1009
+ 1. **Extracts rate limit info** from response headers (`Retry-After`, `cf-ray`, `qb-api-ray`)
1010
+ 2. **Calls the `onRateLimit` callback** if configured, allowing you to log or monitor
1011
+ 3. **Waits before retrying** - uses the `Retry-After` header if present, otherwise exponential backoff with jitter
1012
+ 4. **Retries the request** up to `maxAttempts` times (default: 3)
1013
+ 5. **Throws a `RateLimitError`** if all retries are exhausted
1014
+
1015
+ ```
1016
+ Request fails with 429
1017
+ ↓
1018
+ Extract Retry-After header
1019
+ ↓
1020
+ Call onRateLimit callback (if set)
1021
+ ↓
1022
+ Wait (Retry-After or exponential backoff)
1023
+ ↓
1024
+ Retry request (up to maxAttempts)
1025
+ ↓
1026
+ Throw RateLimitError if exhausted
1027
+ ```
1028
+
1029
+ ### Retry Configuration
1030
+
1031
+ ```typescript
1032
+ const qb = createClient({
1033
+ realm: 'mycompany',
1034
+ auth: { type: 'user-token', userToken: 'token' },
1035
+ rateLimit: {
1036
+ retry: {
1037
+ maxAttempts: 5, // Default: 3
1038
+ initialDelayMs: 1000, // Default: 1000ms
1039
+ maxDelayMs: 30000, // Default: 30000ms
1040
+ multiplier: 2, // Default: 2
1041
+ },
1042
+ },
1043
+ });
1044
+ ```
1045
+
1046
+ The backoff formula with jitter: `delay = initialDelayMs * (multiplier ^ attempt) ± 10%`
1047
+
1048
+ ### Proactive Throttling
1049
+
1050
+ Prevent 429 errors entirely by throttling requests client-side using a sliding window algorithm:
1051
+
1052
+ ```typescript
1053
+ const qb = createClient({
1054
+ realm: 'mycompany',
1055
+ auth: { type: 'user-token', userToken: 'token' },
1056
+ rateLimit: {
1057
+ proactiveThrottle: {
1058
+ enabled: true,
1059
+ requestsPer10Seconds: 100,
1060
+ },
1061
+ },
1062
+ });
1063
+ ```
1064
+
1065
+ This tracks request timestamps and blocks new requests when the limit would be exceeded, waiting until the oldest request exits the 10-second window.
1066
+
1067
+ ### Rate Limit Callback
1068
+
1069
+ Get notified when rate limited (called before retry):
1070
+
1071
+ ```typescript
1072
+ const qb = createClient({
1073
+ realm: 'mycompany',
1074
+ auth: { type: 'user-token', userToken: 'token' },
1075
+ rateLimit: {
1076
+ onRateLimit: (info) => {
1077
+ console.log(`Rate limited on ${info.requestUrl}`);
1078
+ console.log(`Retry after: ${info.retryAfter} seconds`);
1079
+ console.log(`Ray ID: ${info.qbApiRay}`);
1080
+ console.log(`Attempt: ${info.attempt}`);
1081
+ },
1082
+ },
1083
+ });
1084
+ ```
1085
+
1086
+ ### Handling RateLimitError
1087
+
1088
+ If retries are exhausted, a `RateLimitError` is thrown:
1089
+
1090
+ ```typescript
1091
+ import { RateLimitError } from 'quickbase-js';
1092
+
1093
+ try {
1094
+ await qb.getApp({ appId: 'bpqe82s1' });
1095
+ } catch (error) {
1096
+ if (error instanceof RateLimitError) {
1097
+ console.log(`Rate limited after ${error.rateLimitInfo.attempt} attempts`);
1098
+ console.log(`Retry after: ${error.retryAfter} seconds`);
1099
+ }
1100
+ }
1101
+ ```
1102
+
377
1103
  ## Migration from v1
378
1104
 
379
1105
  ### Authentication
@@ -383,7 +1109,7 @@ try {
383
1109
  const qb = quickbase({ realm: 'company', userToken: 'token' });
384
1110
 
385
1111
  // v2 - user token (explicit type)
386
- const client = createClient({
1112
+ const qb = createClient({
387
1113
  realm: 'company',
388
1114
  auth: { type: 'user-token', userToken: 'token' },
389
1115
  });
@@ -392,7 +1118,7 @@ const client = createClient({
392
1118
  const qb = quickbase({ realm: 'company', useTempTokens: true });
393
1119
 
394
1120
  // v2 - temp tokens in Code Pages
395
- const client = createClient({
1121
+ const qb = createClient({
396
1122
  realm: 'company',
397
1123
  auth: { type: 'temp-token' },
398
1124
  });
@@ -406,8 +1132,8 @@ await qb.withPaginationDisabled(async () => qb.runQuery({ body: { from: tableId
406
1132
  await qb.withPaginationLimit(100, async () => qb.runQuery({ body: { from: tableId } }));
407
1133
 
408
1134
  // v2 - fluent API
409
- await client.runQuery({ from: tableId }).noPaginate();
410
- await client.runQuery({ from: tableId }).paginate({ limit: 100 });
1135
+ await qb.runQuery({ from: tableId }).noPaginate();
1136
+ await qb.runQuery({ from: tableId }).paginate({ limit: 100 });
411
1137
  ```
412
1138
 
413
1139
  ### Request Body
@@ -417,7 +1143,7 @@ await client.runQuery({ from: tableId }).paginate({ limit: 100 });
417
1143
  await qb.runQuery({ body: { from: tableId, select: [3, 6] } });
418
1144
 
419
1145
  // v2 - direct parameters
420
- await client.runQuery({ from: tableId, select: [3, 6] });
1146
+ await qb.runQuery({ from: tableId, select: [3, 6] });
421
1147
  ```
422
1148
 
423
1149
  ## Development
@@ -445,6 +1171,22 @@ npm run typecheck
445
1171
  npm run spec:generate
446
1172
  ```
447
1173
 
1174
+ ### Updating the OpenAPI Spec
1175
+
1176
+ The `spec/` directory is a Git submodule pointing to [quickbase-spec](https://github.com/DrewBradfordXYZ/quickbase-spec). Each SDK pins to a specific commit, so spec updates are controlled:
1177
+
1178
+ ```bash
1179
+ # Update to latest spec
1180
+ cd spec
1181
+ git pull origin main
1182
+ cd ..
1183
+ git add spec
1184
+ git commit -m "Update quickbase-spec submodule"
1185
+
1186
+ # Regenerate types
1187
+ npm run spec:generate
1188
+ ```
1189
+
448
1190
  ### Spec Tools
449
1191
 
450
1192
  ```bash
@@ -461,14 +1203,17 @@ npm run spec:generate # Generate TypeScript types
461
1203
 
462
1204
  ### Documentation
463
1205
 
464
- Generate API documentation from source code:
1206
+ API documentation is auto-generated from source code and hosted at:
1207
+ **https://drewbradfordxyz.github.io/quickbase-js/**
1208
+
1209
+ To generate docs locally:
465
1210
 
466
1211
  ```bash
467
1212
  npm run docs # Generate docs to ./docs
468
1213
  npm run docs:watch # Watch mode for development
469
1214
  ```
470
1215
 
471
- The documentation is generated using [TypeDoc](https://typedoc.org/) and extracts JSDoc comments from the source code. Open `docs/index.html` to view locally.
1216
+ Documentation is built with [TypeDoc](https://typedoc.org/) and auto-deployed to GitHub Pages on every push to main.
472
1217
 
473
1218
  ## License
474
1219