@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.21

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/src/http.js CHANGED
@@ -1,33 +1,154 @@
1
- import { transformRequest, transformResponse } from './transforms.js';
1
+ import { markReplayed } from "./replayed.js";
2
+ import { transformRequest, transformResponse } from "./transforms.js";
3
+ import { takeRefs } from "./batchRef.js";
4
+ import { uuidv7 } from "./ids.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Batches: which calls a batch carries, and which go over the wire anyway.
8
+ //
9
+ // A batch is an OBJECT (`client.batch()`, or the argument `client.batched(fn)`
10
+ // hands `fn`), a view of the client with the same bundles. A write made on the
11
+ // batch queues; a call made on the client itself always goes over the wire,
12
+ // whatever batches happen to be open. So the editor, the importer and the
13
+ // app's chrome can share one client, and a write by code that knows nothing
14
+ // about a batch can never land inside it. Every call reaching this layer is
15
+ // one of three things, and the third column is what a new endpoint has to
16
+ // pick.
17
+ //
18
+ // 1. A WRITE of project data: an entity, its metadata, a layer, a membership,
19
+ // a comment. This is what a batch is for, so on a batch it QUEUES. It is
20
+ // the default and needs no flag.
21
+ //
22
+ // 2. A READ. Every GET is answered from the wire even when made on a batch:
23
+ // the caller gets data rather than `{ batched: true }`, it takes no slot in
24
+ // the batch's results, and server-side it runs against the pool rather than
25
+ // the batch's transaction connection. A read that travels as a POST
26
+ // (`query`) says so with `outOfBand`.
27
+ //
28
+ // 3. An OUT-OF-BAND SIGNAL: stopping a service request, reporting a service's
29
+ // progress or result, taking or dropping a document lock, forgetting a
30
+ // service, an admin action on the server itself. It is shaped like a write
31
+ // but carries no project data, and its whole value is that it happens NOW.
32
+ // Pass `outOfBand`: made on a batch it still goes over the wire, and it
33
+ // never joins an open logical operation. The audit group is a label for
34
+ // the writes, and a signal that is never audited would mark the group
35
+ // written and leave the relabel PATCH 404ing on a group nothing ever
36
+ // created.
37
+ //
38
+ // A broadcast message (`messages.sendMessage`) sits between a write and a
39
+ // signal: it stays batchable, so a message queued after the writes goes out
40
+ // after them, but it writes nothing to the audit log either. `noOperation`
41
+ // keeps it out of an open logical operation without making it out of band.
42
+ //
43
+ // Strict mode stamps the expected document-version onto every write, queued
44
+ // or not. The server checks the first write of a batch request that names the
45
+ // document and skips the rest of that document's, so the bump one op causes
46
+ // does not 409 the next. A batch split past MAX_BATCH_OPS restamps each later
47
+ // request with the version the one before it left (see submitBatch).
48
+ //
49
+ // `noBatch` is not part of that judgment. It marks the calls a batch cannot
50
+ // carry at all (the multipart media and avatar uploads, the user-data store's
51
+ // put and delete, and minting an API token or an invite, whose answer is a
52
+ // secret the server refuses to keep with a keyed batch's answer) and raises
53
+ // so the caller finds out. A
54
+ // batch inside a batch is refused by the batch itself. Never put it on a read: it turns a swallowed read into a
55
+ // thrown one, which is what the chrome hit when an unrelated import was
56
+ // running. A blobless DELETE beside an upload is not one of them: it carries
57
+ // nothing the transport cannot express, so it takes its class from the three
58
+ // above like anything else.
59
+ // ---------------------------------------------------------------------------
2
60
 
3
61
  // Default per-request timeout (ms). Applied to every request unless the client
4
62
  // is constructed with a different `timeout` (0 / null disables it). Note: this
5
63
  // also bounds media up/downloads — bump it (or disable) for very large files.
6
64
  export const DEFAULT_TIMEOUT_MS = 30000;
7
65
 
66
+ // Batch submissions get their own, longer budget. A batch runs as ONE server
67
+ // transaction holding the single SQLite write lock for its whole duration, and
68
+ // aborting the HTTP request does not cancel it — so a short timeout buys
69
+ // nothing and costs a retry stacked on a write that is still running.
70
+ export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
71
+
72
+ /**
73
+ * Note how far the server's clock is from this machine's, from a response's
74
+ * Date header, for `client.serverNow()`. A time the server stamped (an audit
75
+ * entry's `ts`) has to be judged against the server's clock: a browser clock
76
+ * minutes off would misjudge it. A response with no readable Date header
77
+ * leaves the last offset as it was.
78
+ */
79
+ export function noteServerClock(client, headers) {
80
+ const at = Date.parse(headers?.get?.("date") ?? "");
81
+ if (!Number.isNaN(at)) client.serverClockOffsetMs = at - Date.now();
82
+ }
83
+
8
84
  /**
9
85
  * Extract and update document versions from response headers and body.
86
+ *
87
+ * `historical` marks a read made with `as-of`: its body carries the version
88
+ * the document HAD then, which is not what a strict-mode write must claim
89
+ * next, so the body is ignored and only the header (always the live
90
+ * version) is learned from. Without this, viewing an old state and then
91
+ * writing 409s on a version that stopped being current long ago.
10
92
  */
11
- export function extractDocumentVersions(client, responseHeaders, responseBody = null) {
12
- const docVersionsHeader = responseHeaders.get('X-Document-Versions');
93
+ export function extractDocumentVersions(
94
+ client,
95
+ responseHeaders,
96
+ responseBody = null,
97
+ { historical = false } = {},
98
+ ) {
99
+ // A replayed answer (see the Idempotency-Key note below) carries the
100
+ // versions right after the first send, and this page may have read newer
101
+ // ones since. Keep the higher of each.
102
+ const replayed = responseHeaders.get(REPLAYED_HEADER) === "true";
103
+ // Past fifty documents the server leaves the list out and says how many it
104
+ // left out. Any version held may be one of them, so all are forgotten, and
105
+ // makeRequest reads the strict-mode document's again (learnOmittedVersion).
106
+ if (responseHeaders.get("X-Document-Versions-Omitted")) {
107
+ client.documentVersions = {};
108
+ client.documentVersionsOmitted = true;
109
+ }
110
+ const docVersionsHeader = responseHeaders.get("X-Document-Versions");
13
111
  if (docVersionsHeader) {
14
112
  try {
15
113
  const versionsMap = JSON.parse(docVersionsHeader);
16
- if (typeof versionsMap === 'object' && versionsMap !== null) {
114
+ if (typeof versionsMap === "object" && versionsMap !== null) {
17
115
  // Clone once, then assign — cloning inside the loop is O(n²) and pointless.
18
- client.documentVersions = { ...client.documentVersions, ...versionsMap };
116
+ client.documentVersions = mergeVersions(
117
+ client.documentVersions,
118
+ versionsMap,
119
+ replayed,
120
+ );
19
121
  }
20
122
  } catch (e) {
21
- console.warn('Failed to parse document versions header:', e);
123
+ console.warn("Failed to parse document versions header:", e);
124
+ }
125
+ }
126
+
127
+ if (!historical && responseBody && typeof responseBody === "object") {
128
+ if (responseBody["document/id"] && responseBody["document/version"]) {
129
+ client.documentVersions = mergeVersions(
130
+ client.documentVersions,
131
+ { [responseBody["document/id"]]: responseBody["document/version"] },
132
+ replayed,
133
+ );
22
134
  }
23
135
  }
136
+ }
24
137
 
25
- if (responseBody && typeof responseBody === 'object') {
26
- if (responseBody['document/id'] && responseBody['document/version']) {
27
- client.documentVersions = { ...client.documentVersions };
28
- client.documentVersions[responseBody['document/id']] = responseBody['document/version'];
138
+ /**
139
+ * `held` with `learned` merged in. A replayed answer only raises a version:
140
+ * it is what the document was right after the first send.
141
+ */
142
+ export function mergeVersions(held, learned, replayed = false) {
143
+ if (!replayed) return { ...held, ...learned };
144
+ const merged = { ...held };
145
+ for (const [docId, version] of Object.entries(learned)) {
146
+ const current = merged[docId];
147
+ if (!(typeof current === "number" && current >= version)) {
148
+ merged[docId] = version;
29
149
  }
30
150
  }
151
+ return merged;
31
152
  }
32
153
 
33
154
  /**
@@ -37,7 +158,11 @@ export async function parseErrorBody(response) {
37
158
  try {
38
159
  return await response.json();
39
160
  } catch (_) {
40
- return { message: await response.text().catch(() => 'Unable to read error response') };
161
+ return {
162
+ message: await response
163
+ .text()
164
+ .catch(() => "Unable to read error response"),
165
+ };
41
166
  }
42
167
  }
43
168
 
@@ -45,7 +170,11 @@ export async function parseErrorBody(response) {
45
170
  * Create an enriched error from a failed HTTP response.
46
171
  */
47
172
  export function makeHttpError(response, errorData, url, method) {
48
- const serverMessage = errorData?.error || errorData?.message || response.statusText || 'Unknown error';
173
+ const serverMessage =
174
+ errorData?.error ||
175
+ errorData?.message ||
176
+ response.statusText ||
177
+ "Unknown error";
49
178
  const error = new Error(`HTTP ${response.status} ${serverMessage} at ${url}`);
50
179
  error.status = response.status;
51
180
  error.statusText = response.statusText;
@@ -59,7 +188,9 @@ export function makeHttpError(response, errorData, url, method) {
59
188
  * Create a network error (status 0). Timeout aborts get a clearer message.
60
189
  */
61
190
  export function makeNetworkError(originalError, url, method) {
62
- const timedOut = originalError?.name === 'TimeoutError' || originalError?.name === 'AbortError';
191
+ const timedOut =
192
+ originalError?.name === "TimeoutError" ||
193
+ originalError?.name === "AbortError";
63
194
  const message = timedOut
64
195
  ? `Request timed out at ${url}`
65
196
  : `Network error: ${originalError.message} at ${url}`;
@@ -68,50 +199,290 @@ export function makeNetworkError(originalError, url, method) {
68
199
  error.url = url;
69
200
  error.method = method;
70
201
  error.originalError = originalError;
202
+ // The browser says it has no network, so the request most likely never
203
+ // left this machine. A timeout is not that: the request went, and may land.
204
+ if (!timedOut && globalThis.navigator?.onLine === false) error.offline = true;
71
205
  return error;
72
206
  }
73
207
 
208
+ // Retry budget for 503 "Database busy" responses. Plaid serializes writers on
209
+ // a single SQLite write lock; a writer that can't get it within the server's
210
+ // busy_timeout is refused with 503. That refusal is definitive — the
211
+ // transaction never opened, or was rolled back whole — so repeating the
212
+ // request is safe, including for a batch (which is all-or-nothing by
213
+ // construction). Retrying here is what lets a long import ride out someone
214
+ // else's slow write instead of dying partway through.
215
+ export const BUSY_RETRIES = 4;
216
+ export const BUSY_BACKOFF_MS = 250;
217
+
218
+ /**
219
+ * Run `attempt`, retrying while it rejects with a 503. Exponential backoff
220
+ * with full jitter, so two clients that collide don't march in lockstep and
221
+ * collide again on every retry.
222
+ *
223
+ * `attempt` must perform the whole request, not just await a prepared one:
224
+ * a fetch body and an AbortSignal.timeout are both single-use.
225
+ */
226
+ export async function retryWhileBusy(
227
+ attempt,
228
+ { retries = BUSY_RETRIES, baseDelayMs = BUSY_BACKOFF_MS, onRetry } = {},
229
+ ) {
230
+ for (let i = 0; ; i += 1) {
231
+ try {
232
+ return await attempt();
233
+ } catch (error) {
234
+ if (error?.status !== 503 || i >= retries) throw error;
235
+ const delay = Math.round(baseDelayMs * 2 ** i * (0.5 + Math.random()));
236
+ onRetry?.({ attempt: i + 1, retries, delay, error });
237
+ await new Promise((resolve) => setTimeout(resolve, delay));
238
+ }
239
+ }
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // The Idempotency-Key.
244
+ //
245
+ // Every write that is not a signal (`outOfBand`) or a call a batch cannot
246
+ // carry (`noBatch`: an upload, user data, a call that mints a secret) goes out
247
+ // with an `Idempotency-Key` header, and so does every batch request (a queued
248
+ // op has none: its batch request carries one). The server keeps the answer to a
249
+ // keyed write for a day, and the same key sent again for the same request is
250
+ // answered from it with `Idempotent-Replayed: true`, writing nothing. So a
251
+ // write whose answer was lost (no response, 502, 504) can be sent again
252
+ // safely: it lands once. `makeRequest` and `_postBatch` do that three times
253
+ // themselves (retryUnknown), and the error that escapes carries the key as
254
+ // `idempotencyKey`.
255
+ //
256
+ // Inside a logical operation opened with `keys` (client.keySeed()), the nth
257
+ // keyed request that joins it takes the key `<seed>.<n>` and, from its first
258
+ // run, a pinned strict-mode stamp, so running the operation again from the
259
+ // top sends byte for byte the requests the first run sent: those that landed
260
+ // are replayed and the rest run. A request that does not join the operation
261
+ // mints its own key.
262
+ // ---------------------------------------------------------------------------
263
+
264
+ export const IDEMPOTENCY_HEADER = "Idempotency-Key";
265
+ export const REPLAYED_HEADER = "Idempotent-Replayed";
266
+
267
+ // Delays before sending a keyed write again when its answer was lost.
268
+ export const UNKNOWN_RETRY_DELAYS_MS = [1000, 3000, 9000];
269
+
270
+ /**
271
+ * Whether `error` leaves the outcome unknown: no response came (status 0),
272
+ * or a proxy answered for the server (502, 504). The write may have landed.
273
+ */
274
+ export function isUnknownOutcome(error) {
275
+ const status = error?.status;
276
+ return status === 0 || status === 502 || status === 504;
277
+ }
278
+
279
+ /**
280
+ * Whether a create refused with `status` and body `data` was made by an
281
+ * earlier send of the same work: `409 id-taken` for an id in `minted` (a Set
282
+ * of the ids the work minted for its creates), and not for a row deleted
283
+ * since (there is nothing to go on with). The one rule for a lost answer's
284
+ * create. The Python twin is `minted_taken`.
285
+ */
286
+ export function mintedTaken(status, data, minted) {
287
+ return (
288
+ status === 409 &&
289
+ data?.error === "id-taken" &&
290
+ !data.deleted &&
291
+ !!minted?.has(data.id)
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Run `attempt`, sending it again after each delay in `delaysMs` while it
297
+ * fails with an unknown outcome. Only for a keyed write, whose resend is
298
+ * answered from the first send when that one landed. Not when the browser
299
+ * says it is offline: then the request never left, and the caller waits for
300
+ * the network instead. Full jitter, as retryWhileBusy.
301
+ */
302
+ export async function retryUnknown(
303
+ attempt,
304
+ { delaysMs = UNKNOWN_RETRY_DELAYS_MS, onRetry } = {},
305
+ ) {
306
+ for (let i = 0; ; i += 1) {
307
+ try {
308
+ return await attempt();
309
+ } catch (error) {
310
+ if (!isUnknownOutcome(error) || error.offline || i >= delaysMs.length) {
311
+ throw error;
312
+ }
313
+ const delay = Math.round(delaysMs[i] * (0.5 + Math.random()));
314
+ onRetry?.({ attempt: i + 1, retries: delaysMs.length, delay, error });
315
+ await new Promise((resolve) => setTimeout(resolve, delay));
316
+ }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * The key for the next keyed request, and the strict-mode stamp it must
322
+ * claim when an earlier run of its operation already sent it. `joins` says
323
+ * whether the request joins the open logical operation.
324
+ * Returns `{ key, pin, record }`: `pin` is undefined when nothing is pinned,
325
+ * and `record(version)` pins what this request claimed, the first time.
326
+ */
327
+ export function nextIdempotencyKey(client, joins) {
328
+ const frame = joins ? client.operationGroup?.frames.at(-1) : null;
329
+ const keys = frame?.keys;
330
+ if (!keys) return { key: uuidv7(), pin: undefined, record: () => {} };
331
+ const n = frame.count;
332
+ frame.count += 1;
333
+ const pinned = keys.stamps.has(n);
334
+ return {
335
+ key: `${keys.seed}.${n}`,
336
+ pin: pinned ? keys.stamps.get(n) : undefined,
337
+ record: (version) => {
338
+ if (!pinned) keys.stamps.set(n, version ?? null);
339
+ },
340
+ };
341
+ }
342
+
74
343
  /**
75
344
  * Build a fetch AbortSignal that fires after `timeout` ms, or undefined when
76
345
  * timeouts are disabled / unsupported.
77
346
  */
78
347
  export function timeoutSignal(timeout) {
79
- if (timeout && timeout > 0 && typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
348
+ if (
349
+ timeout &&
350
+ timeout > 0 &&
351
+ typeof AbortSignal !== "undefined" &&
352
+ AbortSignal.timeout
353
+ ) {
80
354
  return AbortSignal.timeout(timeout);
81
355
  }
82
356
  return undefined;
83
357
  }
84
358
 
85
359
  /**
86
- * Generic request method handling all fetch logic.
87
- *
88
- * Options:
89
- * body - Object body, run through transformRequest
90
- * rawBody - Body value passed directly (no transform). Mutually exclusive with body.
91
- * formData - If true, body is FormData; skip Content-Type header
92
- * queryParams - Object of query param key/values to append
93
- * noBatch - If true, throw when in batch mode
94
- * skipResponseTransform - Return raw parsed JSON (no transformResponse)
95
- * noAuth - Skip Authorization header
96
- * binaryResponse - Return arrayBuffer instead of JSON/text
97
- * timeout - Per-request timeout in ms overriding client.timeout
98
- * for this call (0/null disables). Used for known-long
99
- * ops like project delete.
360
+ * Send a request through XMLHttpRequest so the caller can watch the bytes go
361
+ * up: fetch cannot report upload progress. Resolves to a Response, so the rest
362
+ * of the pipeline is the same either way. The timeout is a STALL timeout,
363
+ * re-armed by every progress event, so a large file on a slow link is never
364
+ * cut off while it is still moving, only once nothing has moved for `timeout`
365
+ * ms. Rejects the way fetch does: a TypeError for a network failure, an error
366
+ * named TimeoutError for a stall.
100
367
  */
101
- export async function makeRequest(client, method, path, options = {}) {
368
+ export function xhrSend(
369
+ url,
370
+ { method, headers, body },
371
+ { onUploadProgress, timeout } = {},
372
+ ) {
373
+ return new Promise((resolve, reject) => {
374
+ const xhr = new XMLHttpRequest();
375
+ xhr.open(method, url);
376
+ xhr.responseType = "arraybuffer";
377
+ for (const [name, value] of Object.entries(headers || {}))
378
+ xhr.setRequestHeader(name, value);
379
+
380
+ let timer = null;
381
+ const disarm = () => clearTimeout(timer);
382
+ const arm = () => {
383
+ if (!timeout || timeout <= 0) return;
384
+ disarm();
385
+ timer = setTimeout(() => {
386
+ xhr.abort();
387
+ reject(
388
+ Object.assign(new Error(`Request timed out at ${url}`), {
389
+ name: "TimeoutError",
390
+ }),
391
+ );
392
+ }, timeout);
393
+ };
394
+
395
+ xhr.upload.onprogress = (e) => {
396
+ arm();
397
+ if (onUploadProgress) {
398
+ onUploadProgress({
399
+ loaded: e.loaded,
400
+ total: e.lengthComputable ? e.total : null,
401
+ });
402
+ }
403
+ };
404
+ xhr.onprogress = arm;
405
+ xhr.onload = () => {
406
+ disarm();
407
+ if (xhr.status === 0) {
408
+ reject(new TypeError(`Failed to fetch ${url}`));
409
+ return;
410
+ }
411
+ const responseHeaders = new Headers();
412
+ for (const line of (xhr.getAllResponseHeaders() || "")
413
+ .trim()
414
+ .split(/\r?\n/)) {
415
+ const i = line.indexOf(":");
416
+ if (i > 0)
417
+ responseHeaders.append(
418
+ line.slice(0, i).trim(),
419
+ line.slice(i + 1).trim(),
420
+ );
421
+ }
422
+ // A Response refuses a body for these statuses.
423
+ const responseBody = [204, 205, 304].includes(xhr.status)
424
+ ? null
425
+ : xhr.response;
426
+ resolve(
427
+ new Response(responseBody, {
428
+ status: xhr.status,
429
+ statusText: xhr.statusText,
430
+ headers: responseHeaders,
431
+ }),
432
+ );
433
+ };
434
+ xhr.onerror = () => {
435
+ disarm();
436
+ reject(new TypeError(`Failed to fetch ${url}`));
437
+ };
438
+ xhr.onabort = () => {
439
+ disarm();
440
+ reject(
441
+ Object.assign(new Error(`Request aborted at ${url}`), {
442
+ name: "AbortError",
443
+ }),
444
+ );
445
+ };
446
+
447
+ arm();
448
+ xhr.send(body);
449
+ });
450
+ }
451
+
452
+ /**
453
+ * Everything a request is before it goes anywhere: the URL with its query
454
+ * params and the stamps strict mode, a per-call audit message and an open
455
+ * logical operation add, plus the transformed body. Shared by the wire path
456
+ * (`makeRequest`) and the batch path (`queueRequest`), so a queued op is
457
+ * exactly the request that would have gone out. Strict mode stamps every
458
+ * write, queued or not, and `stampedDocument` names the document it stamped
459
+ * for (null when it stamped nothing). `stampedGroup` is the open logical
460
+ * operation it joined, if any, for `makeRequest` to mark written once the
461
+ * server has taken the request.
462
+ */
463
+ export function prepareRequest(client, method, path, options = {}) {
102
464
  const {
103
465
  body,
104
466
  rawBody,
105
467
  formData,
106
468
  queryParams,
107
- noBatch,
108
- skipResponseTransform,
109
- noAuth,
110
- binaryResponse,
469
+ outOfBand,
470
+ noOperation,
111
471
  auditMessage,
112
- timeout,
472
+ pinnedVersion,
473
+ versioned,
113
474
  } = options;
114
475
 
476
+ // A write must not go out on a lock that lapsed. `documents.locked()`
477
+ // records the loss here when its keep-alive cannot renew, and from that
478
+ // moment the block is holding nothing: an edit by somebody else can already
479
+ // have landed between the read the work was planned from and the write about
480
+ // to go out. Reads pass, and so do the lock routes themselves, which is how
481
+ // the block still releases on its way out.
482
+ if (client.documentLockLost && method !== "GET" && !path.endsWith("/lock")) {
483
+ throw client.documentLockLost;
484
+ }
485
+
115
486
  // Build URL
116
487
  let url = `${client.baseUrl}${path}`;
117
488
 
@@ -140,114 +511,389 @@ export async function makeRequest(client, method, path, options = {}) {
140
511
  requestBody = transformRequest(body);
141
512
  }
142
513
 
143
- // Strict mode: append document-version for non-GET requests.
144
- // Inside a batch, stamp ONLY the first write: batches run atomically
145
- // server-side, so a version check on the first op gives whole-batch OCC
146
- // semantics, while stamping every op would 409 the second op against the
147
- // version bump the first op itself caused (every queued op captures the
148
- // same pre-batch version).
149
- if (client.strictModeDocumentId && method !== 'GET'
150
- && !(client.isBatching && client.batchVersionStamped)) {
514
+ // Strict mode: append document-version for non-GET requests, on EVERY
515
+ // queued write of a batch. The server validates the first write it can
516
+ // (one whose route resolves a document) and skips the rest of that
517
+ // document's, so the version bump a sub-op causes does not 409 the next
518
+ // one. Stamping only the first write was silently no check at all
519
+ // whenever that write was one the route ignores, such as a vocabulary
520
+ // entry's metadata, which is exactly what the igt editor queues first.
521
+ // `stampedDocument` names the document the stamp is for, so a batch split
522
+ // into several requests can restamp its later ones (see submitBatch). An
523
+ // out-of-band signal (a lock, a service's progress, a query) is no write of
524
+ // the document and carries none.
525
+ // A resend of an operation's request claims what its first run claimed
526
+ // (`pinnedVersion`, null for no claim), so the two are the same request.
527
+ let stampedDocument = null;
528
+ let stampedVersion = null;
529
+ // A write with its own precondition (`versioned: false`, a text edit sent
530
+ // with the digest of the body it was made on) goes without the stamp.
531
+ if (
532
+ client.strictModeDocumentId &&
533
+ method !== "GET" &&
534
+ !outOfBand &&
535
+ versioned !== false
536
+ ) {
151
537
  const docId = client.strictModeDocumentId;
152
- if (client.documentVersions[docId]) {
153
- const docVersion = client.documentVersions[docId];
154
- const separator = url.includes('?') ? '&' : '?';
538
+ const docVersion =
539
+ pinnedVersion !== undefined
540
+ ? pinnedVersion
541
+ : client.documentVersions[docId];
542
+ if (docVersion) {
543
+ const separator = url.includes("?") ? "&" : "?";
155
544
  url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
156
- if (client.isBatching) client.batchVersionStamped = true;
545
+ stampedDocument = docId;
546
+ stampedVersion = docVersion;
157
547
  }
158
548
  }
159
549
 
160
550
  // Per-call custom audit-log message (overrides the auto-generated
161
- // description of THIS write). Unlike document-version this has no OCC
162
- // self-conflict, so it is stamped on every queued batch op, not just the
163
- // first. The server templates `{param}` placeholders against the endpoint's
164
- // own path/query/body params.
165
- if (auditMessage && method !== 'GET') {
166
- const separator = url.includes('?') ? '&' : '?';
551
+ // description of THIS write), stamped on the one write it was given to. The
552
+ // server templates `{param}` placeholders against the endpoint's own
553
+ // path/query/body params.
554
+ if (auditMessage && method !== "GET") {
555
+ const separator = url.includes("?") ? "&" : "?";
167
556
  url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
168
557
  }
169
558
 
170
559
  // Logical-operation group (see client.beginOperation): stamp every write
171
560
  // with the group id; the message rides along too so the server can label
172
- // the group lazily on whichever tagged write lands first.
173
- if (client.operationGroup && method !== 'GET') {
561
+ // the group lazily on whichever tagged write lands first. The group counts
562
+ // as written only once such a write has been taken (`makeRequest`): a group
563
+ // whose only write was refused was never made, and relabelling it 404s.
564
+ //
565
+ // An out-of-band signal is not one of those writes (see the note at the top
566
+ // of this file): it never lands in the audit log, so a stamp does nothing
567
+ // server-side while `written` promises a group that will never exist, and
568
+ // the relabel PATCH then 404s. A broadcast message (`noOperation`) is
569
+ // never audited either.
570
+ let stampedGroup = null;
571
+ if (
572
+ client.operationGroup &&
573
+ method !== "GET" &&
574
+ !outOfBand &&
575
+ !noOperation
576
+ ) {
174
577
  const group = client.operationGroup;
175
- const separator = url.includes('?') ? '&' : '?';
578
+ const separator = url.includes("?") ? "&" : "?";
176
579
  url += `${separator}group-id=${encodeURIComponent(group.id)}`;
177
- if (group.message) url += `&group-message=${encodeURIComponent(group.message)}`;
178
- group.written = true;
580
+ if (group.message)
581
+ url += `&group-message=${encodeURIComponent(group.message)}`;
582
+ if (group.kind) url += `&group-kind=${encodeURIComponent(group.kind)}`;
583
+ if (group.ref) url += `&group-ref=${encodeURIComponent(group.ref)}`;
584
+ stampedGroup = group;
179
585
  }
180
586
 
181
- // Batch mode
182
- if (client.isBatching) {
183
- if (noBatch) {
184
- throw new Error(`This endpoint cannot be used in batch mode: ${path}`);
185
- }
186
- const operation = {
187
- path: url.replace(client.baseUrl, ''),
188
- method: method.toUpperCase(),
189
- };
190
- if (requestBody !== undefined) {
191
- operation.body = requestBody;
192
- }
193
- client.batchOperations.push(operation);
194
- return { batched: true };
587
+ return { url, requestBody, stampedDocument, stampedVersion, stampedGroup };
588
+ }
589
+
590
+ /**
591
+ * Whether a write joins the open logical operation (see prepareRequest).
592
+ */
593
+ export function joinsOperation(client, method, options = {}) {
594
+ return Boolean(
595
+ client.operationGroup &&
596
+ method !== "GET" &&
597
+ !options.outOfBand &&
598
+ !options.noOperation,
599
+ );
600
+ }
601
+
602
+ /**
603
+ * Whether a call made on the client goes out with an Idempotency-Key.
604
+ */
605
+ export function takesIdempotencyKey(method, options = {}) {
606
+ return (
607
+ method !== "GET" &&
608
+ !options.outOfBand &&
609
+ !options.noBatch
610
+ );
611
+ }
612
+
613
+ /**
614
+ * `path` with its strict-mode `document-version` claim replaced by `version`,
615
+ * or dropped when `version` is unknown. For the later requests of a batch
616
+ * split past MAX_BATCH_OPS: each op was stamped with the version the document
617
+ * had when it was queued, and every request before it has since moved the
618
+ * document on.
619
+ */
620
+ export function restampDocumentVersion(path, version) {
621
+ const [base, query = ""] = path.split(/\?(.*)/s);
622
+ const params = query
623
+ .split("&")
624
+ .filter((p) => p && !p.startsWith("document-version="));
625
+ if (version !== undefined && version !== null) {
626
+ params.push(`document-version=${encodeURIComponent(version)}`);
627
+ }
628
+ return params.length ? `${base}?${params.join("&")}` : base;
629
+ }
630
+
631
+ /**
632
+ * The strict-mode document whose version a response left out
633
+ * (X-Document-Versions-Omitted) and the client has not learned since, or
634
+ * null. Such a write would go out unstamped, which is no check at all.
635
+ */
636
+ function omittedStrictDocument(client) {
637
+ const docId = client.strictModeDocumentId;
638
+ return docId &&
639
+ client.documentVersionsOmitted &&
640
+ client.documentVersions[docId] === undefined
641
+ ? docId
642
+ : null;
643
+ }
644
+
645
+ /**
646
+ * Read the strict-mode document once to learn the version a response left
647
+ * out. Done right after that response, so the client's own large write is
648
+ * not mistaken for somebody else's edit at the next save, and again before a
649
+ * strict write when that read failed.
650
+ */
651
+ async function learnOmittedVersion(client) {
652
+ const docId = omittedStrictDocument(client);
653
+ if (!docId) return;
654
+ // One read at a time: a write made while it is in flight waits for it
655
+ // rather than going out unstamped.
656
+ if (!client._learningOmittedVersion) {
657
+ client._learningOmittedVersion = makeRequest(
658
+ client,
659
+ "GET",
660
+ `/api/v1/documents/${docId}`,
661
+ { learningOmittedVersion: true },
662
+ ).finally(() => {
663
+ client._learningOmittedVersion = null;
664
+ });
665
+ }
666
+ await client._learningOmittedVersion;
667
+ }
668
+
669
+ async function learnOmittedVersionQuietly(client) {
670
+ try {
671
+ await learnOmittedVersion(client);
672
+ } catch (_) {
673
+ /* the write went through; the next strict write asks again */
674
+ }
675
+ }
676
+
677
+ /**
678
+ * A call made on a batch (see `PlaidClient#batch`). A write of project data is
679
+ * queued as one operation of the batch and answers `{ batched: true }`; its
680
+ * result is the matching entry of what `submit()` resolves to. A read, and a
681
+ * signal marked `outOfBand`, is the client's to make and goes over the wire
682
+ * now, exactly as if it had been made on the client.
683
+ */
684
+ export async function queueRequest(batch, method, path, options = {}) {
685
+ if (method === "GET" || options.outOfBand) {
686
+ return batch.client._request(method, path, options);
687
+ }
688
+ if (!batch.open) {
689
+ throw new Error(`This batch was already submitted or aborted: ${path}`);
690
+ }
691
+ if (options.noBatch) {
692
+ throw new Error(`This endpoint cannot be used in a batch: ${path}`);
195
693
  }
694
+ const { url, requestBody, stampedDocument, stampedGroup } = prepareRequest(
695
+ batch.client,
696
+ method,
697
+ path,
698
+ options,
699
+ );
700
+ const operation = {
701
+ path: url.replace(batch.client.baseUrl, ""),
702
+ method: method.toUpperCase(),
703
+ };
704
+ if (requestBody !== undefined) {
705
+ // Each b.ref() in the body goes beside it, as the path to a null the
706
+ // server fills with the id (batchRef.js).
707
+ const { body, refs } = takeRefs(batch, requestBody);
708
+ operation.body = body;
709
+ if (refs) operation.refs = refs;
710
+ }
711
+ batch.operations.push(operation);
712
+ batch.stampedDocuments.push(stampedDocument);
713
+ batch.stampedGroups.push(stampedGroup);
714
+ return { batched: true };
715
+ }
716
+
717
+ /**
718
+ * Generic request method handling all fetch logic.
719
+ *
720
+ * Options:
721
+ * body - Object body, run through transformRequest
722
+ * rawBody - Body value passed directly (no transform). Mutually exclusive with body.
723
+ * formData - If true, body is FormData; skip Content-Type header
724
+ * queryParams - Object of query param key/values to append
725
+ * noBatch - If true, throw when made on a batch. Only for calls a
726
+ * batch cannot carry at all (see the note at the
727
+ * top of this file); never for a read.
728
+ * outOfBand - If true, the call is a signal rather than a write of
729
+ * project data: made on a batch it still goes over the
730
+ * wire, and it never joins an open logical operation (see
731
+ * the note at the top of this file).
732
+ * noOperation - If true, the call never joins an open logical operation
733
+ * but still queues on a batch like a write. For a
734
+ * broadcast message, which is never audited.
735
+ * versioned - If false, strict mode does not stamp the call with the
736
+ * document version. For a write that carries its own
737
+ * precondition (a text edit with `base`).
738
+ * skipResponseTransform - Return raw parsed JSON (no transformResponse)
739
+ * noAuth - Skip Authorization header
740
+ * binaryResponse - Return arrayBuffer instead of JSON/text
741
+ * timeout - Per-request timeout in ms overriding client.timeout
742
+ * for this call (0/null disables). Used for known-long
743
+ * ops like project delete.
744
+ * onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
745
+ * unknown) as the request body goes up. In a browser the
746
+ * request then travels by XMLHttpRequest (see xhrSend),
747
+ * where the timeout only fires when the upload stalls;
748
+ * elsewhere the callback is ignored and fetch is used.
749
+ */
750
+ export async function makeRequest(client, method, path, options = {}) {
751
+ const {
752
+ formData,
753
+ skipResponseTransform,
754
+ noAuth,
755
+ binaryResponse,
756
+ timeout,
757
+ onUploadProgress,
758
+ learningOmittedVersion,
759
+ } = options;
760
+ if (method !== "GET" && omittedStrictDocument(client)) {
761
+ await learnOmittedVersion(client);
762
+ }
763
+ const keyed = takesIdempotencyKey(method, options);
764
+ const joins = joinsOperation(client, method, options);
765
+ // The ids the joined operation minted for what it creates (withOperation's
766
+ // `minted`): a create refused 409 id-taken for one of them was made by an
767
+ // earlier send of this operation, and answers as made.
768
+ const minted = joins ? client.operationGroup?.frames.at(-1)?.minted : null;
769
+ const { key, pin, record } = keyed ? nextIdempotencyKey(client, joins) : {};
770
+ const { url, requestBody, stampedGroup, stampedVersion } = prepareRequest(
771
+ client,
772
+ method,
773
+ path,
774
+ keyed ? { ...options, pinnedVersion: pin } : options,
775
+ );
776
+ if (keyed) record(stampedVersion);
196
777
 
197
778
  // Build fetch options
198
779
  const headers = {};
199
780
  if (!noAuth) {
200
- headers['Authorization'] = `Bearer ${client.token}`;
781
+ headers["Authorization"] = `Bearer ${client.token}`;
201
782
  }
202
783
  if (!formData) {
203
- headers['Content-Type'] = 'application/json';
784
+ headers["Content-Type"] = "application/json";
204
785
  }
786
+ if (keyed) headers[IDEMPOTENCY_HEADER] = key;
205
787
 
206
788
  const fetchOptions = { method, headers };
207
789
  if (requestBody !== undefined) {
208
790
  fetchOptions.body = formData ? requestBody : JSON.stringify(requestBody);
209
791
  }
210
- const signal = timeoutSignal(timeout !== undefined ? timeout : client.timeout);
211
- if (signal) fetchOptions.signal = signal;
792
+
793
+ // A fresh AbortSignal.timeout per attempt: the signal fires once and stays
794
+ // aborted, so reusing it would make every retry fail instantly.
795
+ const timeoutMs = timeout !== undefined ? timeout : client.timeout;
796
+ const send = () => {
797
+ if (onUploadProgress && typeof XMLHttpRequest !== "undefined") {
798
+ return xhrSend(url, fetchOptions, {
799
+ onUploadProgress,
800
+ timeout: timeoutMs,
801
+ });
802
+ }
803
+ const signal = timeoutSignal(timeoutMs);
804
+ return fetch(url, signal ? { ...fetchOptions, signal } : fetchOptions);
805
+ };
806
+
807
+ // One attempt: a 503, and for a keyed write a 502 or 504, surfaces as a
808
+ // throw so the retries below can see it. Other failures are thrown from
809
+ // the checks after it.
810
+ const attempt = async () => {
811
+ let res;
812
+ try {
813
+ res = await send();
814
+ } catch (e) {
815
+ throw makeNetworkError(e, url, method);
816
+ }
817
+ if (
818
+ res.status === 503 ||
819
+ (keyed && (res.status === 502 || res.status === 504))
820
+ ) {
821
+ throw makeHttpError(res, await parseErrorBody(res), url, method);
822
+ }
823
+ return res;
824
+ };
212
825
 
213
826
  try {
214
- const response = await fetch(url, fetchOptions);
827
+ const onRetry = (info) => client._noteRetry?.(info);
828
+ const response = keyed
829
+ ? await retryUnknown(() => retryWhileBusy(attempt, { onRetry }), {
830
+ delaysMs: client.retryDelaysMs,
831
+ onRetry,
832
+ })
833
+ : await retryWhileBusy(attempt, { onRetry });
834
+ noteServerClock(client, response.headers);
215
835
 
216
836
  if (!response.ok) {
217
- const error = makeHttpError(response, await parseErrorBody(response), url, method);
837
+ const errorData = await parseErrorBody(response);
838
+ // Answered as made only for a single create, the one whose answer is
839
+ // the id alone. A bulk or a batch taken so is made whole (it is one
840
+ // transaction), and a caller that sends one decides what it answers.
841
+ if (
842
+ mintedTaken(response.status, errorData, minted) &&
843
+ requestBody?.id === errorData.id
844
+ ) {
845
+ if (stampedGroup) stampedGroup.written = true;
846
+ return transformResponse({ id: errorData.id });
847
+ }
848
+ const error = makeHttpError(response, errorData, url, method);
218
849
  // 401 means the token is missing/expired/invalid. Fire the app's auth-error
219
850
  // handler once (it discards the token and routes back to login). 403
220
851
  // (forbidden — authenticated but not permitted) deliberately does NOT.
221
- if (response.status === 401 && typeof client.onAuthError === 'function' && !client._authErrorFired) {
852
+ if (
853
+ response.status === 401 &&
854
+ typeof client.onAuthError === "function" &&
855
+ !client._authErrorFired
856
+ ) {
222
857
  client._authErrorFired = true;
223
- try { client.onAuthError(error); } catch (_) { /* handler must not mask the original error */ }
858
+ try {
859
+ client.onAuthError(error);
860
+ } catch (_) {
861
+ /* handler must not mask the original error */
862
+ }
224
863
  }
225
864
  throw error;
226
865
  }
866
+ // The server took it, so the operation's group exists.
867
+ if (stampedGroup) stampedGroup.written = true;
227
868
 
228
869
  // Binary response (getMedia)
229
870
  if (binaryResponse) {
230
871
  extractDocumentVersions(client, response.headers);
872
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
231
873
  return await response.arrayBuffer();
232
874
  }
233
875
 
234
876
  // JSON or text response
235
- const contentType = response.headers.get('content-type');
236
- if (contentType && contentType.includes('application/json')) {
877
+ const contentType = response.headers.get("content-type");
878
+ if (contentType && contentType.includes("application/json")) {
237
879
  const data = await response.json();
238
- extractDocumentVersions(client, response.headers, data);
239
- if (skipResponseTransform) {
240
- return data;
241
- }
242
- return transformResponse(data);
880
+ extractDocumentVersions(client, response.headers, data, {
881
+ historical: /[?&]as-of=/.test(url),
882
+ });
883
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
884
+ const answer = skipResponseTransform ? data : transformResponse(data);
885
+ return response.headers.get(REPLAYED_HEADER) === "true"
886
+ ? markReplayed(answer)
887
+ : answer;
243
888
  } else {
244
889
  extractDocumentVersions(client, response.headers);
890
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
245
891
  return await response.text();
246
892
  }
247
893
  } catch (error) {
248
- if (error.status !== undefined) {
249
- throw error;
250
- }
251
- throw makeNetworkError(error, url, method);
894
+ const escaped =
895
+ error.status !== undefined ? error : makeNetworkError(error, url, method);
896
+ if (keyed) escaped.idempotencyKey = key;
897
+ throw escaped;
252
898
  }
253
899
  }