quickbase-js 0.1.0-beta.11 → 0.1.0-beta.12

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [0.1.0-beta.12](https://github.com/DrewBradfordXYZ/quickbase-js/compare/v0.1.0-beta.11...v0.1.0-beta.12) (2025-03-23)
6
+
7
+
8
+ ### Features
9
+
10
+ * enable true concurrency with throttle bucket ([8dc4dee](https://github.com/DrewBradfordXYZ/quickbase-js/commit/8dc4deeaddb4365f4f64a8bd07390c7071f739f7))
11
+
5
12
  ## [0.1.0-beta.11](https://github.com/DrewBradfordXYZ/quickbase-js/compare/v0.1.0-beta.10...v0.1.0-beta.11) (2025-03-23)
6
13
 
7
14
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A Typescript API client for the [Quickbase JSON RESTful API](https://developer.quickbase.com/).
4
4
 
5
- Support for various authentication strategies (`user token`, `temporary tokens`, `SSO token`), `rate limiting`, `429` and `401` error handling retries and automatic `date` conversion. It uses a Proxy to dynamically invoke API methods derived from the OpenAPI specification.
5
+ Support for various authentication strategies (`user token`, `temporary tokens`, `SSO token`), `rate limiting`, `429`(rate limit) error and `401`(auth) error handling retries and automatic `date` conversion. It uses a Proxy to dynamically invoke API methods derived from the OpenAPI specification.
6
6
 
7
7
  Browser and Node.js support. ESM and UMD builds are available.
8
8
 
@@ -155,37 +155,29 @@ _User options:_
155
155
 
156
156
  _Advanced user options:_
157
157
 
158
- - **`throttle`** (`{ rate: number; burst: number }`, optional): Configures rate limiting, where `rate` is requests per second and `burst` is the maximum burst of requests. Defaults to `{ rate: 10, burst: 10 }`.
159
-
158
+ - **`throttle`** (`{ rate: number; burst: number }`, optional): Configures rate limiting to manage how many API requests are sent at once and how quickly additional requests follow. Defaults to `{ rate: 5, burst: 3 }`.
159
+ - **`burst`**: The number of requests that can start immediately (concurrent calls). Default is `3`, meaning up to 3 requests run at the same time.
160
+ - **`rate`**: The number of requests allowed per second after the initial burst. Default is `5`, pacing extra requests at one every ~200ms.
161
+ - **How It Works**: Taking the default setting for example: requests in general take ~500ms to finish. Imagine having 6 requests, 3 start right away finishing at ~500ms. Because the rate is set to 200ms, 2 request tokens have built up during this time, so the next 2 requests are sent out immediately when the first 3 finnish, leaving 1 burst spot available. The third request waits the extra 100ms to be dispatched starting at ~600ms using up the remaining burst spot, which takes another ~500ms to finish. All requests finishing around ~1100ms total. This keeps you safely under Quickbase’s 10 requests/sec limit while maximizing speed. A more liberal setting of `{ rate: 10, burst: 5 }` allows 5 requests at once, with request tokens building up every ~100ms, finishing 6 requests in ~1000ms.
160
162
  - **`maxRetries`** (`number`, optional): The maximum number of retries for failed requests. Defaults to `3`.
161
-
162
163
  - **`retryDelay`** (`number`, optional): The base delay (in milliseconds) between retries, which increases exponentially. Defaults to `1000`.
163
-
164
164
  - **`tempTokenLifespan`** (`number`, optional): The lifespan (in milliseconds) of temporary tokens in the cache. Defaults to 4 minutes 50 seconds (290000 ms).
165
-
166
165
  - **`convertDates`** (`boolean`, optional): Converts ISO date strings to `Date` objects in responses. Defaults to `true`.
167
166
 
168
167
  _Overrides and development options:_
169
168
 
170
- - **`fetchApi`** (`typeof fetch`, optional): For browser environments, this defaults to the built-in `fetch`. In Node.js, you are able to use `node-fetch` or another compatible library.
171
-
169
+ - **`fetchApi`** (`typeof fetch`, optional): For browser environments, this defaults to the built-in `fetch`. In Node.js, you can use `node-fetch` or another compatible library.
172
170
  - **`baseUrl`** (`string`, optional): The base URL for the QuickBase API. Defaults to `"https://api.quickbase.com/v1"`.
173
-
174
171
  - **`debug`** (`boolean`, optional): Enables debug logging to the console. Defaults to `false`.
175
-
176
- - **`tempToken`** (`string`, optional): If you would like to override the default behavior of tempTokens you can provide your own tempToken.
177
-
178
- - **`tokenCache`** (`TokenCache`, optional): If you would like to use your own token cache, you can provide an instance of `TokenCache`.
172
+ - **`tempToken`** (`string`, optional): Overrides the default behavior of tempTokens by providing your own tempToken.
173
+ - **`tokenCache`** (`TokenCache`, optional): Allows you to use your own token cache instance.
179
174
 
180
175
  ---
181
176
 
182
177
  #### Configuration Notes
183
178
 
184
- - **Authentication**: There are three mutually exclusive authentication methods: `userToken`, `useTempTokens` and `useSso` with `samlToken`. Only one auth method may be active at a time.
185
-
186
- - **`throttle`**: Adjust this to handle 429 Too Many Requests errors. Controls API request pacing to avoid hitting rate limits. For example, `{ rate: 5, burst: 20 }` allows 5 requests per second with a burst capacity of 20.
187
-
188
- - **`tokenCache`**: Temporary tokens are cached with their dbid with a `tempTokenLifespan`. This is automatically setup for you when `useTempTokens` is enabled.
179
+ - **Authentication**: There are three mutually exclusive authentication methods: `userToken`, `useTempTokens`, and `useSso` with `samlToken`. Only one auth method may be active at a time.
180
+ - **`tokenCache`**: Temporary tokens are cached with their dbid and `tempTokenLifespan`. This is automatically set up when `useTempTokens` is enabled.
189
181
 
190
182
  ---
191
183
 
@@ -7648,8 +7648,8 @@ async function invokeMethod(methodName, params, methodMap, baseHeaders, authStra
7648
7648
  const hasBody = "body" in params && params.body !== undefined;
7649
7649
  const body = hasBody ? params.body : undefined;
7650
7650
  const restParams = hasBody
7651
- ? Object.fromEntries(Object.entries(params).filter(([key]) => key !== "body"))
7652
- : { ...params };
7651
+ ? Object.fromEntries(Object.entries(params).filter(([key]) => key !== "body" && key !== "startTime"))
7652
+ : { ...params, startTime: undefined };
7653
7653
  const requestParameters = {
7654
7654
  ...restParams,
7655
7655
  ...(hasBody ? { generated: body } : {}),
@@ -7714,12 +7714,21 @@ async function invokeMethod(methodName, params, methodMap, baseHeaders, authStra
7714
7714
  return { message, status };
7715
7715
  }
7716
7716
  while (attempt < maxAttempts) {
7717
+ let acquired = false;
7717
7718
  try {
7718
7719
  await rateLimiter.throttle();
7719
- const response = await methodInfo.method(requestParameters, requestOptions);
7720
+ acquired = true;
7721
+ const postThrottleTime = Date.now(); // Capture post-throttle time
7722
+ if (params.startTime !== undefined)
7723
+ params.startTime = postThrottleTime; // Update startTime for testing
7724
+ const responsePromise = methodInfo.method(requestParameters, requestOptions);
7725
+ rateLimiter.release(); // Release slot immediately after starting fetch
7726
+ const response = await responsePromise;
7720
7727
  return await processResponse(response);
7721
7728
  }
7722
7729
  catch (error) {
7730
+ if (acquired)
7731
+ rateLimiter.release();
7723
7732
  let status;
7724
7733
  let message;
7725
7734
  let response;
@@ -7732,7 +7741,7 @@ async function invokeMethod(methodName, params, methodMap, baseHeaders, authStra
7732
7741
  ({ message, status } = await parseErrorResponse(response));
7733
7742
  }
7734
7743
  else {
7735
- throw error; // Rethrow truly unexpected errors
7744
+ throw error;
7736
7745
  }
7737
7746
  if (status === 429) {
7738
7747
  if (!(error instanceof ResponseError)) {
@@ -7748,15 +7757,13 @@ async function invokeMethod(methodName, params, methodMap, baseHeaders, authStra
7748
7757
  attempt++;
7749
7758
  continue;
7750
7759
  }
7751
- // Handle authentication errors
7752
7760
  let newToken;
7753
7761
  try {
7754
7762
  newToken = await authStrategy.handleError(status, params, attempt, maxAttempts, debug, methodName);
7755
7763
  }
7756
7764
  catch (authError) {
7757
- throw authError; // Propagate the fetchTempToken error and exit immediately
7765
+ throw authError;
7758
7766
  }
7759
- // Only proceed with retry if we have a valid new token
7760
7767
  if (newToken !== null) {
7761
7768
  token = newToken;
7762
7769
  authStrategy.applyHeaders(baseHeaders, token);
@@ -7766,7 +7773,6 @@ async function invokeMethod(methodName, params, methodMap, baseHeaders, authStra
7766
7773
  attempt++;
7767
7774
  continue;
7768
7775
  }
7769
- // If no new token was provided, throw the original error
7770
7776
  throw new Error(`API Error: ${message} (Status: ${status})`);
7771
7777
  }
7772
7778
  }
@@ -7774,41 +7780,71 @@ async function invokeMethod(methodName, params, methodMap, baseHeaders, authStra
7774
7780
  }
7775
7781
 
7776
7782
  // src/ThrottleBucket.ts
7777
- class ThrottleBucket {
7783
+ class ConcurrentThrottleBucket {
7778
7784
  tokens;
7779
7785
  maxTokens;
7780
- refillRate; // Tokens per second
7786
+ refillRate;
7781
7787
  lastRefill;
7782
- pending = Promise.resolve(); // Queue for sequential execution
7788
+ semaphore;
7783
7789
  constructor(rate, burst) {
7784
7790
  this.tokens = burst;
7785
7791
  this.maxTokens = burst;
7786
7792
  this.refillRate = rate;
7787
7793
  this.lastRefill = Date.now();
7794
+ this.semaphore = new Semaphore(burst);
7788
7795
  }
7789
7796
  refill() {
7790
7797
  const now = Date.now();
7791
- const elapsed = (now - this.lastRefill) / 1000; // Seconds elapsed
7798
+ const elapsed = (now - this.lastRefill) / 1000;
7792
7799
  const newTokens = elapsed * this.refillRate;
7793
7800
  this.tokens = Math.min(this.maxTokens, this.tokens + newTokens);
7794
7801
  this.lastRefill = now;
7795
7802
  }
7796
7803
  async acquire() {
7797
- // Chain the new acquisition onto the pending queue
7798
- const previous = this.pending;
7799
- this.pending = (async () => {
7800
- await previous; // Wait for prior calls to complete
7804
+ await this.semaphore.acquire();
7805
+ try {
7801
7806
  this.refill();
7802
- if (this.tokens >= 1) {
7803
- this.tokens -= 1;
7804
- return;
7807
+ while (this.tokens < 1) {
7808
+ const waitTime = ((1 - this.tokens) / this.refillRate) * 1000;
7809
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
7810
+ this.refill();
7805
7811
  }
7806
- const waitTime = ((1 - this.tokens) / this.refillRate) * 1000; // ms until next token
7807
- await new Promise((resolve) => setTimeout(resolve, waitTime));
7808
- this.refill();
7809
7812
  this.tokens -= 1;
7810
- })();
7811
- await this.pending;
7813
+ }
7814
+ catch (error) {
7815
+ this.semaphore.release();
7816
+ throw error;
7817
+ }
7818
+ }
7819
+ release() {
7820
+ this.semaphore.release();
7821
+ }
7822
+ }
7823
+ class Semaphore {
7824
+ permits;
7825
+ waiting = [];
7826
+ constructor(maxPermits) {
7827
+ this.permits = maxPermits;
7828
+ }
7829
+ async acquire() {
7830
+ if (this.permits > 0) {
7831
+ this.permits -= 1;
7832
+ return;
7833
+ }
7834
+ return new Promise((resolve, reject) => {
7835
+ this.waiting.push({ resolve, reject });
7836
+ });
7837
+ }
7838
+ release() {
7839
+ this.permits += 1;
7840
+ if (this.waiting.length > 0 && this.permits > 0) {
7841
+ const { resolve } = this.waiting.shift();
7842
+ this.permits -= 1;
7843
+ resolve();
7844
+ }
7845
+ }
7846
+ available() {
7847
+ return this.permits;
7812
7848
  }
7813
7849
  }
7814
7850
 
@@ -7816,7 +7852,8 @@ class RateLimiter {
7816
7852
  throttleBucket;
7817
7853
  maxRetries;
7818
7854
  retryDelay;
7819
- constructor(throttleBucket, maxRetries = 3, retryDelay = 1000) {
7855
+ constructor(throttleBucket, // Updated type
7856
+ maxRetries = 3, retryDelay = 1000) {
7820
7857
  this.throttleBucket = throttleBucket;
7821
7858
  this.maxRetries = maxRetries;
7822
7859
  this.retryDelay = retryDelay;
@@ -7826,8 +7863,12 @@ class RateLimiter {
7826
7863
  await this.throttleBucket.acquire();
7827
7864
  }
7828
7865
  }
7866
+ release() {
7867
+ if (this.throttleBucket) {
7868
+ this.throttleBucket.release();
7869
+ }
7870
+ }
7829
7871
  async handle429(error, attempt) {
7830
- // Remove the throw here; let invokeMethod handle exhaustion
7831
7872
  const retryAfter = error.response.headers.get("Retry-After");
7832
7873
  return retryAfter
7833
7874
  ? parseInt(retryAfter, 10) * 1000
@@ -7836,10 +7877,11 @@ class RateLimiter {
7836
7877
  }
7837
7878
 
7838
7879
  function quickbase(config) {
7839
- const { realm, userToken, tempToken, useTempTokens, useSso, samlToken, fetchApi, debug, convertDates = true, tempTokenLifespan = 290000, throttle = { rate: 10, burst: 10 }, maxRetries = 3, retryDelay = 1000, tokenCache: providedTokenCache, baseUrl = "https://api.quickbase.com/v1", } = config;
7880
+ const { realm, userToken, tempToken, useTempTokens, useSso, samlToken, fetchApi, debug, convertDates = true, tempTokenLifespan = 290000, throttle = { rate: 5, burst: 3 }, // Updated default to match passing test
7881
+ maxRetries = 3, retryDelay = 1000, tokenCache: providedTokenCache, baseUrl = "https://api.quickbase.com/v1", } = config;
7840
7882
  const tokenCache = providedTokenCache || new TokenCache(tempTokenLifespan);
7841
7883
  const throttleBucket = throttle
7842
- ? new ThrottleBucket(throttle.rate, throttle.burst)
7884
+ ? new ConcurrentThrottleBucket(throttle.rate, throttle.burst)
7843
7885
  : null;
7844
7886
  const rateLimiter = new RateLimiter(throttleBucket, maxRetries, retryDelay);
7845
7887
  const defaultFetch = typeof globalThis.window !== "undefined"