@larc-iu/plaid-client 0.2.0-alpha.16 → 0.2.0-alpha.18

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
@@ -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
@@ -58,6 +64,18 @@ export const DEFAULT_TIMEOUT_MS = 30000;
58
64
  // nothing and costs a retry stacked on a write that is still running.
59
65
  export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
60
66
 
67
+ /**
68
+ * Note how far the server's clock is from this machine's, from a response's
69
+ * Date header, for `client.serverNow()`. A time the server stamped (an audit
70
+ * entry's `ts`) has to be judged against the server's clock: a browser clock
71
+ * minutes off would misjudge it. A response with no readable Date header
72
+ * leaves the last offset as it was.
73
+ */
74
+ export function noteServerClock(client, headers) {
75
+ const at = Date.parse(headers?.get?.("date") ?? "");
76
+ if (!Number.isNaN(at)) client.serverClockOffsetMs = at - Date.now();
77
+ }
78
+
61
79
  /**
62
80
  * Extract and update document versions from response headers and body.
63
81
  *
@@ -73,6 +91,13 @@ export function extractDocumentVersions(
73
91
  responseBody = null,
74
92
  { historical = false } = {},
75
93
  ) {
94
+ // Past fifty documents the server leaves the list out and says how many it
95
+ // left out. Any version held may be one of them, so all are forgotten, and
96
+ // makeRequest reads the strict-mode document's again (learnOmittedVersion).
97
+ if (responseHeaders.get("X-Document-Versions-Omitted")) {
98
+ client.documentVersions = {};
99
+ client.documentVersionsOmitted = true;
100
+ }
76
101
  const docVersionsHeader = responseHeaders.get("X-Document-Versions");
77
102
  if (docVersionsHeader) {
78
103
  try {
@@ -292,51 +317,25 @@ export function xhrSend(
292
317
  });
293
318
  }
294
319
 
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
320
  /**
323
321
  * Everything a request is before it goes anywhere: the URL with its query
324
322
  * params and the stamps strict mode, a per-call audit message and an open
325
323
  * logical operation add, plus the transformed body. Shared by the wire path
326
324
  * (`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.
325
+ * exactly the request that would have gone out. Strict mode stamps every
326
+ * write, queued or not, and `stampedDocument` names the document it stamped
327
+ * for (null when it stamped nothing).
330
328
  */
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;
329
+ export function prepareRequest(client, method, path, options = {}) {
330
+ const {
331
+ body,
332
+ rawBody,
333
+ formData,
334
+ queryParams,
335
+ outOfBand,
336
+ noOperation,
337
+ auditMessage,
338
+ } = options;
340
339
 
341
340
  // A write must not go out on a lock that lapsed. `documents.locked()`
342
341
  // records the loss here when its keep-alive cannot renew, and from that
@@ -383,20 +382,23 @@ export function prepareRequest(
383
382
  // one. Stamping only the first write was silently no check at all
384
383
  // whenever that write was one the route ignores, such as a vocabulary
385
384
  // entry's metadata, which is exactly what the igt editor queues first.
385
+ // `stampedDocument` names the document the stamp is for, so a batch split
386
+ // into several requests can restamp its later ones (see submitBatch).
387
+ let stampedDocument = null;
386
388
  if (client.strictModeDocumentId && method !== "GET") {
387
389
  const docId = client.strictModeDocumentId;
388
390
  if (client.documentVersions[docId]) {
389
391
  const docVersion = client.documentVersions[docId];
390
392
  const separator = url.includes("?") ? "&" : "?";
391
393
  url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
394
+ stampedDocument = docId;
392
395
  }
393
396
  }
394
397
 
395
398
  // 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.
399
+ // description of THIS write), stamped on the one write it was given to. The
400
+ // server templates `{param}` placeholders against the endpoint's own
401
+ // path/query/body params.
400
402
  if (auditMessage && method !== "GET") {
401
403
  const separator = url.includes("?") ? "&" : "?";
402
404
  url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
@@ -409,17 +411,89 @@ export function prepareRequest(
409
411
  // An out-of-band signal is not one of those writes (see the note at the top
410
412
  // of this file): it never lands in the audit log, so a stamp does nothing
411
413
  // 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) {
414
+ // the relabel PATCH then 404s. A broadcast message (`noOperation`) is
415
+ // never audited either.
416
+ if (
417
+ client.operationGroup &&
418
+ method !== "GET" &&
419
+ !outOfBand &&
420
+ !noOperation
421
+ ) {
414
422
  const group = client.operationGroup;
415
423
  const separator = url.includes("?") ? "&" : "?";
416
424
  url += `${separator}group-id=${encodeURIComponent(group.id)}`;
417
425
  if (group.message)
418
426
  url += `&group-message=${encodeURIComponent(group.message)}`;
427
+ if (group.kind) url += `&group-kind=${encodeURIComponent(group.kind)}`;
428
+ if (group.ref) url += `&group-ref=${encodeURIComponent(group.ref)}`;
419
429
  group.written = true;
420
430
  }
421
431
 
422
- return { url, requestBody };
432
+ return { url, requestBody, stampedDocument };
433
+ }
434
+
435
+ /**
436
+ * `path` with its strict-mode `document-version` claim replaced by `version`,
437
+ * or dropped when `version` is unknown. For the later requests of a batch
438
+ * split past MAX_BATCH_OPS: each op was stamped with the version the document
439
+ * had when it was queued, and every request before it has since moved the
440
+ * document on.
441
+ */
442
+ export function restampDocumentVersion(path, version) {
443
+ const [base, query = ""] = path.split(/\?(.*)/s);
444
+ const params = query
445
+ .split("&")
446
+ .filter((p) => p && !p.startsWith("document-version="));
447
+ if (version !== undefined && version !== null) {
448
+ params.push(`document-version=${encodeURIComponent(version)}`);
449
+ }
450
+ return params.length ? `${base}?${params.join("&")}` : base;
451
+ }
452
+
453
+ /**
454
+ * The strict-mode document whose version a response left out
455
+ * (X-Document-Versions-Omitted) and the client has not learned since, or
456
+ * null. Such a write would go out unstamped, which is no check at all.
457
+ */
458
+ function omittedStrictDocument(client) {
459
+ const docId = client.strictModeDocumentId;
460
+ return docId &&
461
+ client.documentVersionsOmitted &&
462
+ client.documentVersions[docId] === undefined
463
+ ? docId
464
+ : null;
465
+ }
466
+
467
+ /**
468
+ * Read the strict-mode document once to learn the version a response left
469
+ * out. Done right after that response, so the client's own large write is
470
+ * not mistaken for somebody else's edit at the next save, and again before a
471
+ * strict write when that read failed.
472
+ */
473
+ async function learnOmittedVersion(client) {
474
+ const docId = omittedStrictDocument(client);
475
+ if (!docId) return;
476
+ // One read at a time: a write made while it is in flight waits for it
477
+ // rather than going out unstamped.
478
+ if (!client._learningOmittedVersion) {
479
+ client._learningOmittedVersion = makeRequest(
480
+ client,
481
+ "GET",
482
+ `/api/v1/documents/${docId}`,
483
+ { learningOmittedVersion: true },
484
+ ).finally(() => {
485
+ client._learningOmittedVersion = null;
486
+ });
487
+ }
488
+ await client._learningOmittedVersion;
489
+ }
490
+
491
+ async function learnOmittedVersionQuietly(client) {
492
+ try {
493
+ await learnOmittedVersion(client);
494
+ } catch (_) {
495
+ /* the write went through; the next strict write asks again */
496
+ }
423
497
  }
424
498
 
425
499
  /**
@@ -439,12 +513,11 @@ export async function queueRequest(batch, method, path, options = {}) {
439
513
  if (options.noBatch) {
440
514
  throw new Error(`This endpoint cannot be used in a batch: ${path}`);
441
515
  }
442
- const { url, requestBody } = prepareRequest(
516
+ const { url, requestBody, stampedDocument } = prepareRequest(
443
517
  batch.client,
444
518
  method,
445
519
  path,
446
520
  options,
447
- batch,
448
521
  );
449
522
  const operation = {
450
523
  path: url.replace(batch.client.baseUrl, ""),
@@ -454,9 +527,40 @@ export async function queueRequest(batch, method, path, options = {}) {
454
527
  operation.body = requestBody;
455
528
  }
456
529
  batch.operations.push(operation);
530
+ batch.stampedDocuments.push(stampedDocument);
457
531
  return { batched: true };
458
532
  }
459
533
 
534
+ /**
535
+ * Generic request method handling all fetch logic.
536
+ *
537
+ * Options:
538
+ * body - Object body, run through transformRequest
539
+ * rawBody - Body value passed directly (no transform). Mutually exclusive with body.
540
+ * formData - If true, body is FormData; skip Content-Type header
541
+ * queryParams - Object of query param key/values to append
542
+ * noBatch - If true, throw when made on a batch. Only for calls the
543
+ * batch transport cannot carry at all (see the note at the
544
+ * top of this file); never for a read.
545
+ * outOfBand - If true, the call is a signal rather than a write of
546
+ * project data: made on a batch it still goes over the
547
+ * wire, and it never joins an open logical operation (see
548
+ * the note at the top of this file).
549
+ * noOperation - If true, the call never joins an open logical operation
550
+ * but still queues on a batch like a write. For a
551
+ * broadcast message, which is never audited.
552
+ * skipResponseTransform - Return raw parsed JSON (no transformResponse)
553
+ * noAuth - Skip Authorization header
554
+ * binaryResponse - Return arrayBuffer instead of JSON/text
555
+ * timeout - Per-request timeout in ms overriding client.timeout
556
+ * for this call (0/null disables). Used for known-long
557
+ * ops like project delete.
558
+ * onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
559
+ * unknown) as the request body goes up. In a browser the
560
+ * request then travels by XMLHttpRequest (see xhrSend),
561
+ * where the timeout only fires when the upload stalls;
562
+ * elsewhere the callback is ignored and fetch is used.
563
+ */
460
564
  export async function makeRequest(client, method, path, options = {}) {
461
565
  const {
462
566
  formData,
@@ -465,7 +569,11 @@ export async function makeRequest(client, method, path, options = {}) {
465
569
  binaryResponse,
466
570
  timeout,
467
571
  onUploadProgress,
572
+ learningOmittedVersion,
468
573
  } = options;
574
+ if (method !== "GET" && omittedStrictDocument(client)) {
575
+ await learnOmittedVersion(client);
576
+ }
469
577
  const { url, requestBody } = prepareRequest(client, method, path, options);
470
578
 
471
579
  // Build fetch options
@@ -506,6 +614,7 @@ export async function makeRequest(client, method, path, options = {}) {
506
614
  }
507
615
  return res;
508
616
  });
617
+ noteServerClock(client, response.headers);
509
618
 
510
619
  if (!response.ok) {
511
620
  const error = makeHttpError(
@@ -535,6 +644,7 @@ export async function makeRequest(client, method, path, options = {}) {
535
644
  // Binary response (getMedia)
536
645
  if (binaryResponse) {
537
646
  extractDocumentVersions(client, response.headers);
647
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
538
648
  return await response.arrayBuffer();
539
649
  }
540
650
 
@@ -545,12 +655,14 @@ export async function makeRequest(client, method, path, options = {}) {
545
655
  extractDocumentVersions(client, response.headers, data, {
546
656
  historical: /[?&]as-of=/.test(url),
547
657
  });
658
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
548
659
  if (skipResponseTransform) {
549
660
  return data;
550
661
  }
551
662
  return transformResponse(data);
552
663
  } else {
553
664
  extractDocumentVersions(client, response.headers);
665
+ if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
554
666
  return await response.text();
555
667
  }
556
668
  } catch (error) {