@larc-iu/plaid-client 0.2.0-alpha.0 → 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/index.d.ts +662 -105
- package/package.json +1 -1
- package/src/http.js +266 -38
- package/src/index.js +1435 -357
- package/src/provenance.js +274 -26
- package/src/serviceSchema.js +50 -30
- package/src/services.js +212 -37
- package/src/sse.js +51 -1
package/package.json
CHANGED
package/src/http.js
CHANGED
|
@@ -1,31 +1,52 @@
|
|
|
1
|
-
import { transformRequest, transformResponse } from
|
|
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(
|
|
12
|
-
|
|
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 ===
|
|
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 = {
|
|
35
|
+
client.documentVersions = {
|
|
36
|
+
...client.documentVersions,
|
|
37
|
+
...versionsMap,
|
|
38
|
+
};
|
|
19
39
|
}
|
|
20
40
|
} catch (e) {
|
|
21
|
-
console.warn(
|
|
41
|
+
console.warn("Failed to parse document versions header:", e);
|
|
22
42
|
}
|
|
23
43
|
}
|
|
24
44
|
|
|
25
|
-
if (responseBody && typeof responseBody ===
|
|
26
|
-
if (responseBody[
|
|
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[
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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,36 +323,52 @@ 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 (
|
|
150
|
-
|
|
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
|
}
|
|
158
338
|
}
|
|
159
339
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// OCC self-conflict, so it is stamped on EVERY queued batch op, not just the
|
|
340
|
+
// Per-call custom audit-log message (overrides the auto-generated
|
|
341
|
+
// description of THIS write). Unlike document-version this has no OCC
|
|
342
|
+
// self-conflict, so it is stamped on every queued batch op, not just the
|
|
164
343
|
// first. The server templates `{param}` placeholders against the endpoint's
|
|
165
344
|
// own path/query/body params.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
345
|
+
if (auditMessage && method !== "GET") {
|
|
346
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
347
|
+
url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Logical-operation group (see client.beginOperation): stamp every write
|
|
351
|
+
// with the group id; the message rides along too so the server can label
|
|
352
|
+
// the group lazily on whichever tagged write lands first.
|
|
353
|
+
if (client.operationGroup && method !== "GET") {
|
|
354
|
+
const group = client.operationGroup;
|
|
355
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
356
|
+
url += `${separator}group-id=${encodeURIComponent(group.id)}`;
|
|
357
|
+
if (group.message)
|
|
358
|
+
url += `&group-message=${encodeURIComponent(group.message)}`;
|
|
359
|
+
group.written = true;
|
|
170
360
|
}
|
|
171
361
|
|
|
172
|
-
// Batch mode
|
|
173
|
-
|
|
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) {
|
|
174
367
|
if (noBatch) {
|
|
175
368
|
throw new Error(`This endpoint cannot be used in batch mode: ${path}`);
|
|
176
369
|
}
|
|
177
370
|
const operation = {
|
|
178
|
-
path: url.replace(client.baseUrl,
|
|
371
|
+
path: url.replace(client.baseUrl, ""),
|
|
179
372
|
method: method.toUpperCase(),
|
|
180
373
|
};
|
|
181
374
|
if (requestBody !== undefined) {
|
|
@@ -188,30 +381,63 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
188
381
|
// Build fetch options
|
|
189
382
|
const headers = {};
|
|
190
383
|
if (!noAuth) {
|
|
191
|
-
headers[
|
|
384
|
+
headers["Authorization"] = `Bearer ${client.token}`;
|
|
192
385
|
}
|
|
193
386
|
if (!formData) {
|
|
194
|
-
headers[
|
|
387
|
+
headers["Content-Type"] = "application/json";
|
|
195
388
|
}
|
|
196
389
|
|
|
197
390
|
const fetchOptions = { method, headers };
|
|
198
391
|
if (requestBody !== undefined) {
|
|
199
392
|
fetchOptions.body = formData ? requestBody : JSON.stringify(requestBody);
|
|
200
393
|
}
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
};
|
|
203
408
|
|
|
204
409
|
try {
|
|
205
|
-
const response = await
|
|
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
|
+
});
|
|
206
419
|
|
|
207
420
|
if (!response.ok) {
|
|
208
|
-
const error = makeHttpError(
|
|
421
|
+
const error = makeHttpError(
|
|
422
|
+
response,
|
|
423
|
+
await parseErrorBody(response),
|
|
424
|
+
url,
|
|
425
|
+
method,
|
|
426
|
+
);
|
|
209
427
|
// 401 means the token is missing/expired/invalid. Fire the app's auth-error
|
|
210
428
|
// handler once (it discards the token and routes back to login). 403
|
|
211
429
|
// (forbidden — authenticated but not permitted) deliberately does NOT.
|
|
212
|
-
if (
|
|
430
|
+
if (
|
|
431
|
+
response.status === 401 &&
|
|
432
|
+
typeof client.onAuthError === "function" &&
|
|
433
|
+
!client._authErrorFired
|
|
434
|
+
) {
|
|
213
435
|
client._authErrorFired = true;
|
|
214
|
-
try {
|
|
436
|
+
try {
|
|
437
|
+
client.onAuthError(error);
|
|
438
|
+
} catch (_) {
|
|
439
|
+
/* handler must not mask the original error */
|
|
440
|
+
}
|
|
215
441
|
}
|
|
216
442
|
throw error;
|
|
217
443
|
}
|
|
@@ -223,10 +449,12 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
223
449
|
}
|
|
224
450
|
|
|
225
451
|
// JSON or text response
|
|
226
|
-
const contentType = response.headers.get(
|
|
227
|
-
if (contentType && contentType.includes(
|
|
452
|
+
const contentType = response.headers.get("content-type");
|
|
453
|
+
if (contentType && contentType.includes("application/json")) {
|
|
228
454
|
const data = await response.json();
|
|
229
|
-
extractDocumentVersions(client, response.headers, data
|
|
455
|
+
extractDocumentVersions(client, response.headers, data, {
|
|
456
|
+
historical: /[?&]as-of=/.test(url),
|
|
457
|
+
});
|
|
230
458
|
if (skipResponseTransform) {
|
|
231
459
|
return data;
|
|
232
460
|
}
|