@larc-iu/plaid-client 0.2.0-alpha.14 → 0.2.0-alpha.17

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.14",
3
+ "version": "0.2.0-alpha.17",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
package/src/created.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The ids a create answered with. A create's response is `{id}` and a bulk
3
+ * create's is `{ids}`, read straight off the call or off its result in a
4
+ * batch (`{status, body}`). Every reader of a create response goes through
5
+ * these, so none guesses at the shape.
6
+ */
7
+
8
+ const isId = (v) => typeof v === "string";
9
+
10
+ /**
11
+ * The id a single create answered with, or undefined when it gave none.
12
+ * @param {Object|null|undefined} result - the create's response, or its batch result
13
+ * @returns {string|undefined}
14
+ */
15
+ export const createdId = (result) => {
16
+ if (isId(result?.id)) return result.id;
17
+ return isId(result?.body?.id) ? result.body.id : undefined;
18
+ };
19
+
20
+ /**
21
+ * The ids a bulk create answered with, in input order, or an empty array
22
+ * when it gave none.
23
+ * @param {Object|null|undefined} result - the bulk create's response, or its batch result
24
+ * @returns {string[]}
25
+ */
26
+ export const createdIds = (result) => {
27
+ if (Array.isArray(result?.ids)) return result.ids;
28
+ return Array.isArray(result?.body?.ids) ? result.body.ids : [];
29
+ };
package/src/http.js CHANGED
@@ -1,46 +1,52 @@
1
1
  import { transformRequest, transformResponse } from "./transforms.js";
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // Batch mode: which calls join a batch, and which go over the wire anyway.
4
+ // Batches: which calls a batch carries, and which go over the wire anyway.
5
5
  //
6
- // `client.batched()` sets ONE flag on the whole client, and every call made
7
- // while it is set is queued, whatever code made it. A browser client is shared
8
- // by an editor, an importer and the app's chrome at once, so the call that
9
- // lands inside someone else's batch is usually not the one that opened it.
10
- // Every call reaching this layer is one of three things, and the third column
11
- // is what a new endpoint has to pick.
6
+ // A batch is an OBJECT (`client.batch()`, or the argument `client.batched(fn)`
7
+ // hands `fn`), a view of the client with the same bundles. A write made on the
8
+ // batch queues; a call made on the client itself always goes over the wire,
9
+ // whatever batches happen to be open. So the editor, the importer and the
10
+ // app's chrome can share one client, and a write by code that knows nothing
11
+ // about a batch can never land inside it. Every call reaching this layer is
12
+ // one of three things, and the third column is what a new endpoint has to
13
+ // pick.
12
14
  //
13
15
  // 1. A WRITE of project data: an entity, its metadata, a layer, a membership,
14
- // a comment. This is what a batch is for, so it QUEUES. It is the default
15
- // and needs no flag.
16
+ // a comment. This is what a batch is for, so on a batch it QUEUES. It is
17
+ // the default and needs no flag.
16
18
  //
17
- // 2. A READ. Pass `bypassBatch`. A queued read is broken three ways: the
18
- // caller gets `{ batched: true }` instead of data, the queued GET takes a
19
- // slot in the batch's results array and shifts every positional read after
20
- // it, and server-side the sub-request runs against the batch's transaction
21
- // connection rather than the pool, where `/query` throws and 500s the whole
22
- // batch, rolling back every write in it. Bypassed, the read is answered
23
- // from the pool and sees exactly the state it would have seen had the batch
24
- // not been open.
19
+ // 2. A READ. Every GET is answered from the wire even when made on a batch:
20
+ // the caller gets data rather than `{ batched: true }`, it takes no slot in
21
+ // the batch's results, and server-side it runs against the pool rather than
22
+ // the batch's transaction connection. A read that travels as a POST
23
+ // (`query`) says so with `outOfBand`.
25
24
  //
26
25
  // 3. An OUT-OF-BAND SIGNAL: stopping a service request, reporting a service's
27
- // progress or result, taking or dropping a document lock, an admin action
28
- // on the server itself. It is shaped like a write but carries no project
29
- // data, and its whole value is that it happens NOW. Queued, it happens at
30
- // submit, or never if the batch aborts, while its caller reads success.
31
- // Pass `bypassBatch` for these too.
26
+ // progress or result, taking or dropping a document lock, forgetting a
27
+ // service, an admin action on the server itself. It is shaped like a write
28
+ // but carries no project data, and its whole value is that it happens NOW.
29
+ // Pass `outOfBand`: made on a batch it still goes over the wire, and it
30
+ // never joins an open logical operation. The audit group is a label for
31
+ // the writes, and a signal that is never audited would mark the group
32
+ // written and leave the relabel PATCH 404ing on a group nothing ever
33
+ // created.
32
34
  //
33
- // A bypassed call neither joins the batch nor spends its stamp. Strict mode
34
- // marks the batch's one expected document-version onto the first QUEUED write,
35
- // and a call that went over the wire on its own is not that write. It does not
36
- // join an open logical operation either: the audit group is a label for the
37
- // writes, and a signal that is never audited would mark the group written and
38
- // leave the relabel PATCH 404ing on a group nothing ever created.
35
+ // A broadcast message (`messages.sendMessage`) sits between a write and a
36
+ // signal: it stays batchable, so a message queued after the writes goes out
37
+ // after them, but it writes nothing to the audit log either. `noOperation`
38
+ // keeps it out of an open logical operation without making it out of band.
39
39
  //
40
- // `noBatch` is not part of that judgment. It marks the five calls the batch
41
- // transport cannot carry at all (a batch inside a batch, the multipart media
42
- // and avatar uploads, the user-data store's put and delete) and raises so the
43
- // caller finds out. Never put it on a read: it turns a swallowed read into a
40
+ // Strict mode stamps the expected document-version onto every write, queued
41
+ // or not. The server checks the first write of a batch request that names the
42
+ // document and skips the rest of that document's, so the bump one op causes
43
+ // does not 409 the next. A batch split past MAX_BATCH_OPS restamps each later
44
+ // request with the version the one before it left (see submitBatch).
45
+ //
46
+ // `noBatch` is not part of that judgment. It marks the four calls the batch
47
+ // transport cannot carry at all (the multipart media and avatar uploads, the
48
+ // user-data store's put and delete) and raises so the caller finds out. A
49
+ // batch inside a batch is refused by the batch itself. Never put it on a read: it turns a swallowed read into a
44
50
  // thrown one, which is what the chrome hit when an unrelated import was
45
51
  // running. A blobless DELETE beside an upload is not one of them: it carries
46
52
  // nothing the transport cannot express, so it takes its class from the three
@@ -73,6 +79,13 @@ export function extractDocumentVersions(
73
79
  responseBody = null,
74
80
  { historical = false } = {},
75
81
  ) {
82
+ // Past fifty documents the server leaves the list out and says how many it
83
+ // left out. Any version held may be one of them, so all are forgotten, and
84
+ // makeRequest reads the strict-mode document's again (learnOmittedVersion).
85
+ if (responseHeaders.get("X-Document-Versions-Omitted")) {
86
+ client.documentVersions = {};
87
+ client.documentVersionsOmitted = true;
88
+ }
76
89
  const docVersionsHeader = responseHeaders.get("X-Document-Versions");
77
90
  if (docVersionsHeader) {
78
91
  try {
@@ -293,45 +306,23 @@ export function xhrSend(
293
306
  }
294
307
 
295
308
  /**
296
- * Generic request method handling all fetch logic.
297
- *
298
- * Options:
299
- * body - Object body, run through transformRequest
300
- * rawBody - Body value passed directly (no transform). Mutually exclusive with body.
301
- * formData - If true, body is FormData; skip Content-Type header
302
- * queryParams - Object of query param key/values to append
303
- * noBatch - If true, throw when in batch mode. Only for calls the
304
- * batch transport cannot carry at all (see the note at the
305
- * top of this file); never for a read.
306
- * bypassBatch - If true, go over the wire even while a batch is open.
307
- * Every read carries it, and so does every out-of-band
308
- * signal (see the note at the top of this file).
309
- * skipResponseTransform - Return raw parsed JSON (no transformResponse)
310
- * noAuth - Skip Authorization header
311
- * binaryResponse - Return arrayBuffer instead of JSON/text
312
- * timeout - Per-request timeout in ms overriding client.timeout
313
- * for this call (0/null disables). Used for known-long
314
- * ops like project delete.
315
- * onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
316
- * unknown) as the request body goes up. In a browser the
317
- * request then travels by XMLHttpRequest (see xhrSend),
318
- * where the timeout only fires when the upload stalls;
319
- * elsewhere the callback is ignored and fetch is used.
309
+ * Everything a request is before it goes anywhere: the URL with its query
310
+ * params and the stamps strict mode, a per-call audit message and an open
311
+ * logical operation add, plus the transformed body. Shared by the wire path
312
+ * (`makeRequest`) and the batch path (`queueRequest`), so a queued op is
313
+ * exactly the request that would have gone out. Strict mode stamps every
314
+ * write, queued or not, and `stampedDocument` names the document it stamped
315
+ * for (null when it stamped nothing).
320
316
  */
321
- export async function makeRequest(client, method, path, options = {}) {
317
+ export function prepareRequest(client, method, path, options = {}) {
322
318
  const {
323
319
  body,
324
320
  rawBody,
325
321
  formData,
326
322
  queryParams,
327
- noBatch,
328
- bypassBatch,
329
- skipResponseTransform,
330
- noAuth,
331
- binaryResponse,
323
+ outOfBand,
324
+ noOperation,
332
325
  auditMessage,
333
- timeout,
334
- onUploadProgress,
335
326
  } = options;
336
327
 
337
328
  // A write must not go out on a lock that lapsed. `documents.locked()`
@@ -340,11 +331,7 @@ export async function makeRequest(client, method, path, options = {}) {
340
331
  // have landed between the read the work was planned from and the write about
341
332
  // to go out. Reads pass, and so do the lock routes themselves, which is how
342
333
  // the block still releases on its way out.
343
- if (
344
- client.documentLockLost &&
345
- method !== "GET" &&
346
- !path.endsWith("/lock")
347
- ) {
334
+ if (client.documentLockLost && method !== "GET" && !path.endsWith("/lock")) {
348
335
  throw client.documentLockLost;
349
336
  }
350
337
 
@@ -376,35 +363,30 @@ export async function makeRequest(client, method, path, options = {}) {
376
363
  requestBody = transformRequest(body);
377
364
  }
378
365
 
379
- // Strict mode: append document-version for non-GET requests.
380
- // Inside a batch, stamp ONLY the first queued write: batches run atomically
381
- // server-side, so a version check on the first op gives whole-batch OCC
382
- // semantics, while stamping every op would 409 the second op against the
383
- // version bump the first op itself caused (every queued op captures the
384
- // same pre-batch version).
385
- if (
386
- client.strictModeDocumentId &&
387
- method !== "GET" &&
388
- !(client.isBatching && client.batchVersionStamped)
389
- ) {
366
+ // Strict mode: append document-version for non-GET requests, on EVERY
367
+ // queued write of a batch. The server validates the first write it can
368
+ // (one whose route resolves a document) and skips the rest of that
369
+ // document's, so the version bump a sub-op causes does not 409 the next
370
+ // one. Stamping only the first write was silently no check at all
371
+ // whenever that write was one the route ignores, such as a vocabulary
372
+ // entry's metadata, which is exactly what the igt editor queues first.
373
+ // `stampedDocument` names the document the stamp is for, so a batch split
374
+ // into several requests can restamp its later ones (see submitBatch).
375
+ let stampedDocument = null;
376
+ if (client.strictModeDocumentId && method !== "GET") {
390
377
  const docId = client.strictModeDocumentId;
391
378
  if (client.documentVersions[docId]) {
392
379
  const docVersion = client.documentVersions[docId];
393
380
  const separator = url.includes("?") ? "&" : "?";
394
381
  url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
395
- // A bypassing call queues nothing, so it must not spend the batch's one
396
- // stamp. `query` is a POST and lands here from app chrome while someone
397
- // else's batch is open: marking the batch stamped there left the first
398
- // real write unversioned and the batch unguarded.
399
- if (client.isBatching && !bypassBatch) client.batchVersionStamped = true;
382
+ stampedDocument = docId;
400
383
  }
401
384
  }
402
385
 
403
386
  // Per-call custom audit-log message (overrides the auto-generated
404
- // description of THIS write). Unlike document-version this has no OCC
405
- // self-conflict, so it is stamped on every queued batch op, not just the
406
- // first. The server templates `{param}` placeholders against the endpoint's
407
- // own path/query/body params.
387
+ // description of THIS write), stamped on the one write it was given to. The
388
+ // server templates `{param}` placeholders against the endpoint's own
389
+ // path/query/body params.
408
390
  if (auditMessage && method !== "GET") {
409
391
  const separator = url.includes("?") ? "&" : "?";
410
392
  url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
@@ -414,16 +396,17 @@ export async function makeRequest(client, method, path, options = {}) {
414
396
  // with the group id; the message rides along too so the server can label
415
397
  // the group lazily on whichever tagged write lands first.
416
398
  //
417
- // A bypassing call is not one of those writes. The out-of-band signals are
418
- // shaped like a write and carry no project data (a lock taken or renewed, a
419
- // cancelled service request, a service reporting its progress, an admin
420
- // control), and `query` is a read that travels as a POST. None of them lands
421
- // in the audit log, so a stamp does nothing server-side while `written`
422
- // promises a group that will never exist: the relabel PATCH then 404s. Same
423
- // rule as the batch and the OCC stamp above, for the same reason: a
424
- // bypassing call belongs to whoever made it, not to whatever operation
425
- // happens to be open on this shared client.
426
- if (client.operationGroup && method !== "GET" && !bypassBatch) {
399
+ // An out-of-band signal is not one of those writes (see the note at the top
400
+ // of this file): it never lands in the audit log, so a stamp does nothing
401
+ // server-side while `written` promises a group that will never exist, and
402
+ // the relabel PATCH then 404s. A broadcast message (`noOperation`) is
403
+ // never audited either.
404
+ if (
405
+ client.operationGroup &&
406
+ method !== "GET" &&
407
+ !outOfBand &&
408
+ !noOperation
409
+ ) {
427
410
  const group = client.operationGroup;
428
411
  const separator = url.includes("?") ? "&" : "?";
429
412
  url += `${separator}group-id=${encodeURIComponent(group.id)}`;
@@ -432,24 +415,152 @@ export async function makeRequest(client, method, path, options = {}) {
432
415
  group.written = true;
433
416
  }
434
417
 
435
- // Batch mode. A `bypassBatch` call is deliberately NOT queued: it belongs to
436
- // whoever made it, not to whatever batch happens to be open on this shared
437
- // client (a document reconcile, an import). See the three classes at the top
438
- // of this file.
439
- if (client.isBatching && !bypassBatch) {
440
- if (noBatch) {
441
- throw new Error(`This endpoint cannot be used in batch mode: ${path}`);
442
- }
443
- const operation = {
444
- path: url.replace(client.baseUrl, ""),
445
- method: method.toUpperCase(),
446
- };
447
- if (requestBody !== undefined) {
448
- operation.body = requestBody;
449
- }
450
- client.batchOperations.push(operation);
451
- return { batched: true };
418
+ return { url, requestBody, stampedDocument };
419
+ }
420
+
421
+ /**
422
+ * `path` with its strict-mode `document-version` claim replaced by `version`,
423
+ * or dropped when `version` is unknown. For the later requests of a batch
424
+ * split past MAX_BATCH_OPS: each op was stamped with the version the document
425
+ * had when it was queued, and every request before it has since moved the
426
+ * document on.
427
+ */
428
+ export function restampDocumentVersion(path, version) {
429
+ const [base, query = ""] = path.split(/\?(.*)/s);
430
+ const params = query
431
+ .split("&")
432
+ .filter((p) => p && !p.startsWith("document-version="));
433
+ if (version !== undefined && version !== null) {
434
+ params.push(`document-version=${encodeURIComponent(version)}`);
435
+ }
436
+ return params.length ? `${base}?${params.join("&")}` : base;
437
+ }
438
+
439
+ /**
440
+ * The strict-mode document whose version a response left out
441
+ * (X-Document-Versions-Omitted) and the client has not learned since, or
442
+ * null. Such a write would go out unstamped, which is no check at all.
443
+ */
444
+ function omittedStrictDocument(client) {
445
+ const docId = client.strictModeDocumentId;
446
+ return docId &&
447
+ client.documentVersionsOmitted &&
448
+ client.documentVersions[docId] === undefined
449
+ ? docId
450
+ : null;
451
+ }
452
+
453
+ /**
454
+ * Read the strict-mode document once to learn the version a response left
455
+ * out. Done right after that response, so the client's own large write is
456
+ * not mistaken for somebody else's edit at the next save, and again before a
457
+ * strict write when that read failed.
458
+ */
459
+ async function learnOmittedVersion(client) {
460
+ const docId = omittedStrictDocument(client);
461
+ if (!docId) return;
462
+ // One read at a time: a write made while it is in flight waits for it
463
+ // rather than going out unstamped.
464
+ if (!client._learningOmittedVersion) {
465
+ client._learningOmittedVersion = makeRequest(
466
+ client,
467
+ "GET",
468
+ `/api/v1/documents/${docId}`,
469
+ { learningOmittedVersion: true },
470
+ ).finally(() => {
471
+ client._learningOmittedVersion = null;
472
+ });
473
+ }
474
+ await client._learningOmittedVersion;
475
+ }
476
+
477
+ async function learnOmittedVersionQuietly(client) {
478
+ try {
479
+ await learnOmittedVersion(client);
480
+ } catch (_) {
481
+ /* the write went through; the next strict write asks again */
482
+ }
483
+ }
484
+
485
+ /**
486
+ * A call made on a batch (see `PlaidClient#batch`). A write of project data is
487
+ * queued as one operation of the batch and answers `{ batched: true }`; its
488
+ * result is the matching entry of what `submit()` resolves to. A read, and a
489
+ * signal marked `outOfBand`, is the client's to make and goes over the wire
490
+ * now, exactly as if it had been made on the client.
491
+ */
492
+ export async function queueRequest(batch, method, path, options = {}) {
493
+ if (method === "GET" || options.outOfBand) {
494
+ return batch.client._request(method, path, options);
495
+ }
496
+ if (!batch.open) {
497
+ throw new Error(`This batch was already submitted or aborted: ${path}`);
498
+ }
499
+ if (options.noBatch) {
500
+ throw new Error(`This endpoint cannot be used in a batch: ${path}`);
501
+ }
502
+ const { url, requestBody, stampedDocument } = prepareRequest(
503
+ batch.client,
504
+ method,
505
+ path,
506
+ options,
507
+ );
508
+ const operation = {
509
+ path: url.replace(batch.client.baseUrl, ""),
510
+ method: method.toUpperCase(),
511
+ };
512
+ if (requestBody !== undefined) {
513
+ operation.body = requestBody;
514
+ }
515
+ batch.operations.push(operation);
516
+ batch.stampedDocuments.push(stampedDocument);
517
+ return { batched: true };
518
+ }
519
+
520
+ /**
521
+ * Generic request method handling all fetch logic.
522
+ *
523
+ * Options:
524
+ * body - Object body, run through transformRequest
525
+ * rawBody - Body value passed directly (no transform). Mutually exclusive with body.
526
+ * formData - If true, body is FormData; skip Content-Type header
527
+ * queryParams - Object of query param key/values to append
528
+ * noBatch - If true, throw when made on a batch. Only for calls the
529
+ * batch transport cannot carry at all (see the note at the
530
+ * top of this file); never for a read.
531
+ * outOfBand - If true, the call is a signal rather than a write of
532
+ * project data: made on a batch it still goes over the
533
+ * wire, and it never joins an open logical operation (see
534
+ * the note at the top of this file).
535
+ * noOperation - If true, the call never joins an open logical operation
536
+ * but still queues on a batch like a write. For a
537
+ * broadcast message, which is never audited.
538
+ * skipResponseTransform - Return raw parsed JSON (no transformResponse)
539
+ * noAuth - Skip Authorization header
540
+ * binaryResponse - Return arrayBuffer instead of JSON/text
541
+ * timeout - Per-request timeout in ms overriding client.timeout
542
+ * for this call (0/null disables). Used for known-long
543
+ * ops like project delete.
544
+ * onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
545
+ * unknown) as the request body goes up. In a browser the
546
+ * request then travels by XMLHttpRequest (see xhrSend),
547
+ * where the timeout only fires when the upload stalls;
548
+ * elsewhere the callback is ignored and fetch is used.
549
+ */
550
+ export async function makeRequest(client, method, path, options = {}) {
551
+ const {
552
+ formData,
553
+ skipResponseTransform,
554
+ noAuth,
555
+ binaryResponse,
556
+ timeout,
557
+ onUploadProgress,
558
+ learningOmittedVersion,
559
+ } = options;
560
+ if (method !== "GET" && omittedStrictDocument(client)) {
561
+ await learnOmittedVersion(client);
452
562
  }
563
+ const { url, requestBody } = prepareRequest(client, method, path, options);
453
564
 
454
565
  // Build fetch options
455
566
  const headers = {};
@@ -518,6 +629,7 @@ export async function makeRequest(client, method, path, options = {}) {
518
629
  // Binary response (getMedia)
519
630
  if (binaryResponse) {
520
631
  extractDocumentVersions(client, response.headers);
632
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
521
633
  return await response.arrayBuffer();
522
634
  }
523
635
 
@@ -528,12 +640,14 @@ export async function makeRequest(client, method, path, options = {}) {
528
640
  extractDocumentVersions(client, response.headers, data, {
529
641
  historical: /[?&]as-of=/.test(url),
530
642
  });
643
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
531
644
  if (skipResponseTransform) {
532
645
  return data;
533
646
  }
534
647
  return transformResponse(data);
535
648
  } else {
536
649
  extractDocumentVersions(client, response.headers);
650
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
537
651
  return await response.text();
538
652
  }
539
653
  } catch (error) {