@larc-iu/plaid-client 0.2.0-alpha.16 → 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/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
@@ -32,15 +32,21 @@ import { transformRequest, transformResponse } from "./transforms.js";
32
32
  // written and leave the relabel PATCH 404ing on a group nothing ever
33
33
  // created.
34
34
  //
35
- // Strict mode stamps the batch's one expected document-version onto the first
36
- // write queued on it (whole-batch OCC: stamping every op would 409 the second
37
- // against the bump the first caused). A call that went over the wire on its
38
- // own carries its own stamp and spends nothing of any batch's.
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 {
@@ -292,51 +305,25 @@ export function xhrSend(
292
305
  });
293
306
  }
294
307
 
295
- /**
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 made on a batch. 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
- * outOfBand - If true, the call is a signal rather than a write of
307
- * project data: made on a batch it still goes over the
308
- * wire, and it never joins an open logical operation (see
309
- * the note at the top of this file).
310
- * skipResponseTransform - Return raw parsed JSON (no transformResponse)
311
- * noAuth - Skip Authorization header
312
- * binaryResponse - Return arrayBuffer instead of JSON/text
313
- * timeout - Per-request timeout in ms overriding client.timeout
314
- * for this call (0/null disables). Used for known-long
315
- * ops like project delete.
316
- * onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
317
- * unknown) as the request body goes up. In a browser the
318
- * request then travels by XMLHttpRequest (see xhrSend),
319
- * where the timeout only fires when the upload stalls;
320
- * elsewhere the callback is ignored and fetch is used.
321
- */
322
308
  /**
323
309
  * Everything a request is before it goes anywhere: the URL with its query
324
310
  * params and the stamps strict mode, a per-call audit message and an open
325
311
  * logical operation add, plus the transformed body. Shared by the wire path
326
312
  * (`makeRequest`) and the batch path (`queueRequest`), so a queued op is
327
- * exactly the request that would have gone out. `batch` is the batch the call
328
- * is being queued on, if any: strict mode stamps its document-version onto the
329
- * first write queued there and no other.
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).
330
316
  */
331
- export function prepareRequest(
332
- client,
333
- method,
334
- path,
335
- options = {},
336
- batch = null,
337
- ) {
338
- const { body, rawBody, formData, queryParams, outOfBand, auditMessage } =
339
- options;
317
+ export function prepareRequest(client, method, path, options = {}) {
318
+ const {
319
+ body,
320
+ rawBody,
321
+ formData,
322
+ queryParams,
323
+ outOfBand,
324
+ noOperation,
325
+ auditMessage,
326
+ } = options;
340
327
 
341
328
  // A write must not go out on a lock that lapsed. `documents.locked()`
342
329
  // records the loss here when its keep-alive cannot renew, and from that
@@ -383,20 +370,23 @@ export function prepareRequest(
383
370
  // one. Stamping only the first write was silently no check at all
384
371
  // whenever that write was one the route ignores, such as a vocabulary
385
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;
386
376
  if (client.strictModeDocumentId && method !== "GET") {
387
377
  const docId = client.strictModeDocumentId;
388
378
  if (client.documentVersions[docId]) {
389
379
  const docVersion = client.documentVersions[docId];
390
380
  const separator = url.includes("?") ? "&" : "?";
391
381
  url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
382
+ stampedDocument = docId;
392
383
  }
393
384
  }
394
385
 
395
386
  // Per-call custom audit-log message (overrides the auto-generated
396
- // description of THIS write). Unlike document-version this has no OCC
397
- // self-conflict, so it is stamped on every queued batch op, not just the
398
- // first. The server templates `{param}` placeholders against the endpoint's
399
- // 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.
400
390
  if (auditMessage && method !== "GET") {
401
391
  const separator = url.includes("?") ? "&" : "?";
402
392
  url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
@@ -409,8 +399,14 @@ export function prepareRequest(
409
399
  // An out-of-band signal is not one of those writes (see the note at the top
410
400
  // of this file): it never lands in the audit log, so a stamp does nothing
411
401
  // server-side while `written` promises a group that will never exist, and
412
- // the relabel PATCH then 404s.
413
- if (client.operationGroup && method !== "GET" && !outOfBand) {
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
+ ) {
414
410
  const group = client.operationGroup;
415
411
  const separator = url.includes("?") ? "&" : "?";
416
412
  url += `${separator}group-id=${encodeURIComponent(group.id)}`;
@@ -419,7 +415,71 @@ export function prepareRequest(
419
415
  group.written = true;
420
416
  }
421
417
 
422
- return { url, requestBody };
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
+ }
423
483
  }
424
484
 
425
485
  /**
@@ -439,12 +499,11 @@ export async function queueRequest(batch, method, path, options = {}) {
439
499
  if (options.noBatch) {
440
500
  throw new Error(`This endpoint cannot be used in a batch: ${path}`);
441
501
  }
442
- const { url, requestBody } = prepareRequest(
502
+ const { url, requestBody, stampedDocument } = prepareRequest(
443
503
  batch.client,
444
504
  method,
445
505
  path,
446
506
  options,
447
- batch,
448
507
  );
449
508
  const operation = {
450
509
  path: url.replace(batch.client.baseUrl, ""),
@@ -454,9 +513,40 @@ export async function queueRequest(batch, method, path, options = {}) {
454
513
  operation.body = requestBody;
455
514
  }
456
515
  batch.operations.push(operation);
516
+ batch.stampedDocuments.push(stampedDocument);
457
517
  return { batched: true };
458
518
  }
459
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
+ */
460
550
  export async function makeRequest(client, method, path, options = {}) {
461
551
  const {
462
552
  formData,
@@ -465,7 +555,11 @@ export async function makeRequest(client, method, path, options = {}) {
465
555
  binaryResponse,
466
556
  timeout,
467
557
  onUploadProgress,
558
+ learningOmittedVersion,
468
559
  } = options;
560
+ if (method !== "GET" && omittedStrictDocument(client)) {
561
+ await learnOmittedVersion(client);
562
+ }
469
563
  const { url, requestBody } = prepareRequest(client, method, path, options);
470
564
 
471
565
  // Build fetch options
@@ -535,6 +629,7 @@ export async function makeRequest(client, method, path, options = {}) {
535
629
  // Binary response (getMedia)
536
630
  if (binaryResponse) {
537
631
  extractDocumentVersions(client, response.headers);
632
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
538
633
  return await response.arrayBuffer();
539
634
  }
540
635
 
@@ -545,12 +640,14 @@ export async function makeRequest(client, method, path, options = {}) {
545
640
  extractDocumentVersions(client, response.headers, data, {
546
641
  historical: /[?&]as-of=/.test(url),
547
642
  });
643
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
548
644
  if (skipResponseTransform) {
549
645
  return data;
550
646
  }
551
647
  return transformResponse(data);
552
648
  } else {
553
649
  extractDocumentVersions(client, response.headers);
650
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
554
651
  return await response.text();
555
652
  }
556
653
  } catch (error) {