@larc-iu/plaid-client 0.2.0-alpha.1 → 0.2.0-alpha.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-alpha.11",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
package/src/http.js CHANGED
@@ -1,31 +1,52 @@
1
- import { transformRequest, transformResponse } from './transforms.js';
1
+ import { transformRequest, transformResponse } from "./transforms.js";
2
2
 
3
3
  // Default per-request timeout (ms). Applied to every request unless the client
4
4
  // is constructed with a different `timeout` (0 / null disables it). Note: this
5
5
  // also bounds media up/downloads — bump it (or disable) for very large files.
6
6
  export const DEFAULT_TIMEOUT_MS = 30000;
7
7
 
8
+ // Batch submissions get their own, longer budget. A batch runs as ONE server
9
+ // transaction holding the single SQLite write lock for its whole duration, and
10
+ // aborting the HTTP request does not cancel it — so a short timeout buys
11
+ // nothing and costs a retry stacked on a write that is still running.
12
+ export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
13
+
8
14
  /**
9
15
  * Extract and update document versions from response headers and body.
16
+ *
17
+ * `historical` marks a read made with `as-of`: its body carries the version
18
+ * the document HAD then, which is not what a strict-mode write must claim
19
+ * next, so the body is ignored and only the header (always the live
20
+ * version) is learned from. Without this, viewing an old state and then
21
+ * writing 409s on a version that stopped being current long ago.
10
22
  */
11
- export function extractDocumentVersions(client, responseHeaders, responseBody = null) {
12
- const docVersionsHeader = responseHeaders.get('X-Document-Versions');
23
+ export function extractDocumentVersions(
24
+ client,
25
+ responseHeaders,
26
+ responseBody = null,
27
+ { historical = false } = {},
28
+ ) {
29
+ const docVersionsHeader = responseHeaders.get("X-Document-Versions");
13
30
  if (docVersionsHeader) {
14
31
  try {
15
32
  const versionsMap = JSON.parse(docVersionsHeader);
16
- if (typeof versionsMap === 'object' && versionsMap !== null) {
33
+ if (typeof versionsMap === "object" && versionsMap !== null) {
17
34
  // Clone once, then assign — cloning inside the loop is O(n²) and pointless.
18
- client.documentVersions = { ...client.documentVersions, ...versionsMap };
35
+ client.documentVersions = {
36
+ ...client.documentVersions,
37
+ ...versionsMap,
38
+ };
19
39
  }
20
40
  } catch (e) {
21
- console.warn('Failed to parse document versions header:', e);
41
+ console.warn("Failed to parse document versions header:", e);
22
42
  }
23
43
  }
24
44
 
25
- if (responseBody && typeof responseBody === 'object') {
26
- if (responseBody['document/id'] && responseBody['document/version']) {
45
+ if (!historical && responseBody && typeof responseBody === "object") {
46
+ if (responseBody["document/id"] && responseBody["document/version"]) {
27
47
  client.documentVersions = { ...client.documentVersions };
28
- client.documentVersions[responseBody['document/id']] = responseBody['document/version'];
48
+ client.documentVersions[responseBody["document/id"]] =
49
+ responseBody["document/version"];
29
50
  }
30
51
  }
31
52
  }
@@ -37,7 +58,11 @@ export async function parseErrorBody(response) {
37
58
  try {
38
59
  return await response.json();
39
60
  } catch (_) {
40
- return { message: await response.text().catch(() => 'Unable to read error response') };
61
+ return {
62
+ message: await response
63
+ .text()
64
+ .catch(() => "Unable to read error response"),
65
+ };
41
66
  }
42
67
  }
43
68
 
@@ -45,7 +70,11 @@ export async function parseErrorBody(response) {
45
70
  * Create an enriched error from a failed HTTP response.
46
71
  */
47
72
  export function makeHttpError(response, errorData, url, method) {
48
- const serverMessage = errorData?.error || errorData?.message || response.statusText || 'Unknown error';
73
+ const serverMessage =
74
+ errorData?.error ||
75
+ errorData?.message ||
76
+ response.statusText ||
77
+ "Unknown error";
49
78
  const error = new Error(`HTTP ${response.status} ${serverMessage} at ${url}`);
50
79
  error.status = response.status;
51
80
  error.statusText = response.statusText;
@@ -59,7 +88,9 @@ export function makeHttpError(response, errorData, url, method) {
59
88
  * Create a network error (status 0). Timeout aborts get a clearer message.
60
89
  */
61
90
  export function makeNetworkError(originalError, url, method) {
62
- const timedOut = originalError?.name === 'TimeoutError' || originalError?.name === 'AbortError';
91
+ const timedOut =
92
+ originalError?.name === "TimeoutError" ||
93
+ originalError?.name === "AbortError";
63
94
  const message = timedOut
64
95
  ? `Request timed out at ${url}`
65
96
  : `Network error: ${originalError.message} at ${url}`;
@@ -71,17 +102,149 @@ export function makeNetworkError(originalError, url, method) {
71
102
  return error;
72
103
  }
73
104
 
105
+ // Retry budget for 503 "Database busy" responses. Plaid serializes writers on
106
+ // a single SQLite write lock; a writer that can't get it within the server's
107
+ // busy_timeout is refused with 503. That refusal is definitive — the
108
+ // transaction never opened, or was rolled back whole — so repeating the
109
+ // request is safe, including for a batch (which is all-or-nothing by
110
+ // construction). Retrying here is what lets a long import ride out someone
111
+ // else's slow write instead of dying partway through.
112
+ export const BUSY_RETRIES = 4;
113
+ export const BUSY_BACKOFF_MS = 250;
114
+
115
+ /**
116
+ * Run `attempt`, retrying while it rejects with a 503. Exponential backoff
117
+ * with full jitter, so two clients that collide don't march in lockstep and
118
+ * collide again on every retry.
119
+ *
120
+ * `attempt` must perform the whole request, not just await a prepared one:
121
+ * a fetch body and an AbortSignal.timeout are both single-use.
122
+ */
123
+ export async function retryWhileBusy(
124
+ attempt,
125
+ { retries = BUSY_RETRIES, baseDelayMs = BUSY_BACKOFF_MS, onRetry } = {},
126
+ ) {
127
+ for (let i = 0; ; i += 1) {
128
+ try {
129
+ return await attempt();
130
+ } catch (error) {
131
+ if (error?.status !== 503 || i >= retries) throw error;
132
+ const delay = Math.round(baseDelayMs * 2 ** i * (0.5 + Math.random()));
133
+ onRetry?.({ attempt: i + 1, retries, delay, error });
134
+ await new Promise((resolve) => setTimeout(resolve, delay));
135
+ }
136
+ }
137
+ }
138
+
74
139
  /**
75
140
  * Build a fetch AbortSignal that fires after `timeout` ms, or undefined when
76
141
  * timeouts are disabled / unsupported.
77
142
  */
78
143
  export function timeoutSignal(timeout) {
79
- if (timeout && timeout > 0 && typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
144
+ if (
145
+ timeout &&
146
+ timeout > 0 &&
147
+ typeof AbortSignal !== "undefined" &&
148
+ AbortSignal.timeout
149
+ ) {
80
150
  return AbortSignal.timeout(timeout);
81
151
  }
82
152
  return undefined;
83
153
  }
84
154
 
155
+ /**
156
+ * Send a request through XMLHttpRequest so the caller can watch the bytes go
157
+ * up: fetch cannot report upload progress. Resolves to a Response, so the rest
158
+ * of the pipeline is the same either way. The timeout is a STALL timeout,
159
+ * re-armed by every progress event, so a large file on a slow link is never
160
+ * cut off while it is still moving, only once nothing has moved for `timeout`
161
+ * ms. Rejects the way fetch does: a TypeError for a network failure, an error
162
+ * named TimeoutError for a stall.
163
+ */
164
+ export function xhrSend(
165
+ url,
166
+ { method, headers, body },
167
+ { onUploadProgress, timeout } = {},
168
+ ) {
169
+ return new Promise((resolve, reject) => {
170
+ const xhr = new XMLHttpRequest();
171
+ xhr.open(method, url);
172
+ xhr.responseType = "arraybuffer";
173
+ for (const [name, value] of Object.entries(headers || {}))
174
+ xhr.setRequestHeader(name, value);
175
+
176
+ let timer = null;
177
+ const disarm = () => clearTimeout(timer);
178
+ const arm = () => {
179
+ if (!timeout || timeout <= 0) return;
180
+ disarm();
181
+ timer = setTimeout(() => {
182
+ xhr.abort();
183
+ reject(
184
+ Object.assign(new Error(`Request timed out at ${url}`), {
185
+ name: "TimeoutError",
186
+ }),
187
+ );
188
+ }, timeout);
189
+ };
190
+
191
+ xhr.upload.onprogress = (e) => {
192
+ arm();
193
+ if (onUploadProgress) {
194
+ onUploadProgress({
195
+ loaded: e.loaded,
196
+ total: e.lengthComputable ? e.total : null,
197
+ });
198
+ }
199
+ };
200
+ xhr.onprogress = arm;
201
+ xhr.onload = () => {
202
+ disarm();
203
+ if (xhr.status === 0) {
204
+ reject(new TypeError(`Failed to fetch ${url}`));
205
+ return;
206
+ }
207
+ const responseHeaders = new Headers();
208
+ for (const line of (xhr.getAllResponseHeaders() || "")
209
+ .trim()
210
+ .split(/\r?\n/)) {
211
+ const i = line.indexOf(":");
212
+ if (i > 0)
213
+ responseHeaders.append(
214
+ line.slice(0, i).trim(),
215
+ line.slice(i + 1).trim(),
216
+ );
217
+ }
218
+ // A Response refuses a body for these statuses.
219
+ const responseBody = [204, 205, 304].includes(xhr.status)
220
+ ? null
221
+ : xhr.response;
222
+ resolve(
223
+ new Response(responseBody, {
224
+ status: xhr.status,
225
+ statusText: xhr.statusText,
226
+ headers: responseHeaders,
227
+ }),
228
+ );
229
+ };
230
+ xhr.onerror = () => {
231
+ disarm();
232
+ reject(new TypeError(`Failed to fetch ${url}`));
233
+ };
234
+ xhr.onabort = () => {
235
+ disarm();
236
+ reject(
237
+ Object.assign(new Error(`Request aborted at ${url}`), {
238
+ name: "AbortError",
239
+ }),
240
+ );
241
+ };
242
+
243
+ arm();
244
+ xhr.send(body);
245
+ });
246
+ }
247
+
85
248
  /**
86
249
  * Generic request method handling all fetch logic.
87
250
  *
@@ -91,12 +254,24 @@ export function timeoutSignal(timeout) {
91
254
  * formData - If true, body is FormData; skip Content-Type header
92
255
  * queryParams - Object of query param key/values to append
93
256
  * noBatch - If true, throw when in batch mode
257
+ * bypassBatch - If true, go over the wire even while a batch is open.
258
+ * For READS only: a batch is a write transaction, its
259
+ * ops return no value to their caller until submit, and
260
+ * the sub-request runs against the tx Connection rather
261
+ * than the pool. A read swallowed by an ambient batch is
262
+ * therefore useless to the caller AND can fail the whole
263
+ * batch (see `query`).
94
264
  * skipResponseTransform - Return raw parsed JSON (no transformResponse)
95
265
  * noAuth - Skip Authorization header
96
266
  * binaryResponse - Return arrayBuffer instead of JSON/text
97
267
  * timeout - Per-request timeout in ms overriding client.timeout
98
268
  * for this call (0/null disables). Used for known-long
99
269
  * ops like project delete.
270
+ * onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
271
+ * unknown) as the request body goes up. In a browser the
272
+ * request then travels by XMLHttpRequest (see xhrSend),
273
+ * where the timeout only fires when the upload stalls;
274
+ * elsewhere the callback is ignored and fetch is used.
100
275
  */
101
276
  export async function makeRequest(client, method, path, options = {}) {
102
277
  const {
@@ -105,11 +280,13 @@ export async function makeRequest(client, method, path, options = {}) {
105
280
  formData,
106
281
  queryParams,
107
282
  noBatch,
283
+ bypassBatch,
108
284
  skipResponseTransform,
109
285
  noAuth,
110
286
  binaryResponse,
111
287
  auditMessage,
112
288
  timeout,
289
+ onUploadProgress,
113
290
  } = options;
114
291
 
115
292
  // Build URL
@@ -146,12 +323,15 @@ export async function makeRequest(client, method, path, options = {}) {
146
323
  // semantics, while stamping every op would 409 the second op against the
147
324
  // version bump the first op itself caused (every queued op captures the
148
325
  // same pre-batch version).
149
- if (client.strictModeDocumentId && method !== 'GET'
150
- && !(client.isBatching && client.batchVersionStamped)) {
326
+ if (
327
+ client.strictModeDocumentId &&
328
+ method !== "GET" &&
329
+ !(client.isBatching && client.batchVersionStamped)
330
+ ) {
151
331
  const docId = client.strictModeDocumentId;
152
332
  if (client.documentVersions[docId]) {
153
333
  const docVersion = client.documentVersions[docId];
154
- const separator = url.includes('?') ? '&' : '?';
334
+ const separator = url.includes("?") ? "&" : "?";
155
335
  url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
156
336
  if (client.isBatching) client.batchVersionStamped = true;
157
337
  }
@@ -162,29 +342,33 @@ export async function makeRequest(client, method, path, options = {}) {
162
342
  // self-conflict, so it is stamped on every queued batch op, not just the
163
343
  // first. The server templates `{param}` placeholders against the endpoint's
164
344
  // own path/query/body params.
165
- if (auditMessage && method !== 'GET') {
166
- const separator = url.includes('?') ? '&' : '?';
345
+ if (auditMessage && method !== "GET") {
346
+ const separator = url.includes("?") ? "&" : "?";
167
347
  url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
168
348
  }
169
349
 
170
350
  // Logical-operation group (see client.beginOperation): stamp every write
171
351
  // with the group id; the message rides along too so the server can label
172
352
  // the group lazily on whichever tagged write lands first.
173
- if (client.operationGroup && method !== 'GET') {
353
+ if (client.operationGroup && method !== "GET") {
174
354
  const group = client.operationGroup;
175
- const separator = url.includes('?') ? '&' : '?';
355
+ const separator = url.includes("?") ? "&" : "?";
176
356
  url += `${separator}group-id=${encodeURIComponent(group.id)}`;
177
- if (group.message) url += `&group-message=${encodeURIComponent(group.message)}`;
357
+ if (group.message)
358
+ url += `&group-message=${encodeURIComponent(group.message)}`;
178
359
  group.written = true;
179
360
  }
180
361
 
181
- // Batch mode
182
- if (client.isBatching) {
362
+ // Batch mode. A `bypassBatch` read is deliberately NOT queued: it belongs to
363
+ // whoever called it, not to whatever batch happens to be open on this shared
364
+ // client (a document reconcile, an import), and it reads the pre-batch state
365
+ // exactly as it would have if the batch were not running.
366
+ if (client.isBatching && !bypassBatch) {
183
367
  if (noBatch) {
184
368
  throw new Error(`This endpoint cannot be used in batch mode: ${path}`);
185
369
  }
186
370
  const operation = {
187
- path: url.replace(client.baseUrl, ''),
371
+ path: url.replace(client.baseUrl, ""),
188
372
  method: method.toUpperCase(),
189
373
  };
190
374
  if (requestBody !== undefined) {
@@ -197,30 +381,63 @@ export async function makeRequest(client, method, path, options = {}) {
197
381
  // Build fetch options
198
382
  const headers = {};
199
383
  if (!noAuth) {
200
- headers['Authorization'] = `Bearer ${client.token}`;
384
+ headers["Authorization"] = `Bearer ${client.token}`;
201
385
  }
202
386
  if (!formData) {
203
- headers['Content-Type'] = 'application/json';
387
+ headers["Content-Type"] = "application/json";
204
388
  }
205
389
 
206
390
  const fetchOptions = { method, headers };
207
391
  if (requestBody !== undefined) {
208
392
  fetchOptions.body = formData ? requestBody : JSON.stringify(requestBody);
209
393
  }
210
- const signal = timeoutSignal(timeout !== undefined ? timeout : client.timeout);
211
- if (signal) fetchOptions.signal = signal;
394
+
395
+ // A fresh AbortSignal.timeout per attempt: the signal fires once and stays
396
+ // aborted, so reusing it would make every retry fail instantly.
397
+ const timeoutMs = timeout !== undefined ? timeout : client.timeout;
398
+ const send = () => {
399
+ if (onUploadProgress && typeof XMLHttpRequest !== "undefined") {
400
+ return xhrSend(url, fetchOptions, {
401
+ onUploadProgress,
402
+ timeout: timeoutMs,
403
+ });
404
+ }
405
+ const signal = timeoutSignal(timeoutMs);
406
+ return fetch(url, signal ? { ...fetchOptions, signal } : fetchOptions);
407
+ };
212
408
 
213
409
  try {
214
- const response = await fetch(url, fetchOptions);
410
+ const response = await retryWhileBusy(async () => {
411
+ const res = await send();
412
+ // Surface a 503 as a throw so retryWhileBusy can see it; other failures
413
+ // are re-thrown from here and handled by the caller below.
414
+ if (res.status === 503) {
415
+ throw makeHttpError(res, await parseErrorBody(res), url, method);
416
+ }
417
+ return res;
418
+ });
215
419
 
216
420
  if (!response.ok) {
217
- const error = makeHttpError(response, await parseErrorBody(response), url, method);
421
+ const error = makeHttpError(
422
+ response,
423
+ await parseErrorBody(response),
424
+ url,
425
+ method,
426
+ );
218
427
  // 401 means the token is missing/expired/invalid. Fire the app's auth-error
219
428
  // handler once (it discards the token and routes back to login). 403
220
429
  // (forbidden — authenticated but not permitted) deliberately does NOT.
221
- if (response.status === 401 && typeof client.onAuthError === 'function' && !client._authErrorFired) {
430
+ if (
431
+ response.status === 401 &&
432
+ typeof client.onAuthError === "function" &&
433
+ !client._authErrorFired
434
+ ) {
222
435
  client._authErrorFired = true;
223
- try { client.onAuthError(error); } catch (_) { /* handler must not mask the original error */ }
436
+ try {
437
+ client.onAuthError(error);
438
+ } catch (_) {
439
+ /* handler must not mask the original error */
440
+ }
224
441
  }
225
442
  throw error;
226
443
  }
@@ -232,10 +449,12 @@ export async function makeRequest(client, method, path, options = {}) {
232
449
  }
233
450
 
234
451
  // JSON or text response
235
- const contentType = response.headers.get('content-type');
236
- if (contentType && contentType.includes('application/json')) {
452
+ const contentType = response.headers.get("content-type");
453
+ if (contentType && contentType.includes("application/json")) {
237
454
  const data = await response.json();
238
- extractDocumentVersions(client, response.headers, data);
455
+ extractDocumentVersions(client, response.headers, data, {
456
+ historical: /[?&]as-of=/.test(url),
457
+ });
239
458
  if (skipResponseTransform) {
240
459
  return data;
241
460
  }