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

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,4 +1,7 @@
1
+ import { markReplayed } from "./replayed.js";
1
2
  import { transformRequest, transformResponse } from "./transforms.js";
3
+ import { takeRefs } from "./batchRef.js";
4
+ import { uuidv7 } from "./ids.js";
2
5
 
3
6
  // ---------------------------------------------------------------------------
4
7
  // Batches: which calls a batch carries, and which go over the wire anyway.
@@ -91,6 +94,10 @@ export function extractDocumentVersions(
91
94
  responseBody = null,
92
95
  { historical = false } = {},
93
96
  ) {
97
+ // A replayed answer (see the Idempotency-Key note below) carries the
98
+ // versions right after the first send, and this page may have read newer
99
+ // ones since. Keep the higher of each.
100
+ const replayed = responseHeaders.get(REPLAYED_HEADER) === "true";
94
101
  // Past fifty documents the server leaves the list out and says how many it
95
102
  // left out. Any version held may be one of them, so all are forgotten, and
96
103
  // makeRequest reads the strict-mode document's again (learnOmittedVersion).
@@ -104,10 +111,11 @@ export function extractDocumentVersions(
104
111
  const versionsMap = JSON.parse(docVersionsHeader);
105
112
  if (typeof versionsMap === "object" && versionsMap !== null) {
106
113
  // Clone once, then assign — cloning inside the loop is O(n²) and pointless.
107
- client.documentVersions = {
108
- ...client.documentVersions,
109
- ...versionsMap,
110
- };
114
+ client.documentVersions = mergeVersions(
115
+ client.documentVersions,
116
+ versionsMap,
117
+ replayed,
118
+ );
111
119
  }
112
120
  } catch (e) {
113
121
  console.warn("Failed to parse document versions header:", e);
@@ -116,13 +124,31 @@ export function extractDocumentVersions(
116
124
 
117
125
  if (!historical && responseBody && typeof responseBody === "object") {
118
126
  if (responseBody["document/id"] && responseBody["document/version"]) {
119
- client.documentVersions = { ...client.documentVersions };
120
- client.documentVersions[responseBody["document/id"]] =
121
- responseBody["document/version"];
127
+ client.documentVersions = mergeVersions(
128
+ client.documentVersions,
129
+ { [responseBody["document/id"]]: responseBody["document/version"] },
130
+ replayed,
131
+ );
122
132
  }
123
133
  }
124
134
  }
125
135
 
136
+ /**
137
+ * `held` with `learned` merged in. A replayed answer only raises a version:
138
+ * it is what the document was right after the first send.
139
+ */
140
+ export function mergeVersions(held, learned, replayed = false) {
141
+ if (!replayed) return { ...held, ...learned };
142
+ const merged = { ...held };
143
+ for (const [docId, version] of Object.entries(learned)) {
144
+ const current = merged[docId];
145
+ if (!(typeof current === "number" && current >= version)) {
146
+ merged[docId] = version;
147
+ }
148
+ }
149
+ return merged;
150
+ }
151
+
126
152
  /**
127
153
  * Read a failed response's body as parsed JSON, falling back to text.
128
154
  */
@@ -171,6 +197,9 @@ export function makeNetworkError(originalError, url, method) {
171
197
  error.url = url;
172
198
  error.method = method;
173
199
  error.originalError = originalError;
200
+ // The browser says it has no network, so the request most likely never
201
+ // left this machine. A timeout is not that: the request went, and may land.
202
+ if (!timedOut && globalThis.navigator?.onLine === false) error.offline = true;
174
203
  return error;
175
204
  }
176
205
 
@@ -208,6 +237,91 @@ export async function retryWhileBusy(
208
237
  }
209
238
  }
210
239
 
240
+ // ---------------------------------------------------------------------------
241
+ // The Idempotency-Key.
242
+ //
243
+ // Every write that is not a signal (`outOfBand`), an upload (`noBatch`) or a
244
+ // call that mints a secret (`noIdempotency`) goes out with an
245
+ // `Idempotency-Key` header, and so does every batch request (a queued op has
246
+ // none: its batch request carries one). The server keeps the answer to a
247
+ // keyed write for a day, and the same key sent again for the same request is
248
+ // answered from it with `Idempotent-Replayed: true`, writing nothing. So a
249
+ // write whose answer was lost (no response, 502, 504) can be sent again
250
+ // safely: it lands once. `makeRequest` and `_postBatch` do that three times
251
+ // themselves (retryUnknown), and the error that escapes carries the key as
252
+ // `idempotencyKey`.
253
+ //
254
+ // Inside a logical operation opened with `keys` (client.keySeed()), the nth
255
+ // keyed request that joins it takes the key `<seed>.<n>` and, from its first
256
+ // run, a pinned strict-mode stamp, so running the operation again from the
257
+ // top sends byte for byte the requests the first run sent: those that landed
258
+ // are replayed and the rest run. A request that does not join the operation
259
+ // mints its own key.
260
+ // ---------------------------------------------------------------------------
261
+
262
+ export const IDEMPOTENCY_HEADER = "Idempotency-Key";
263
+ export const REPLAYED_HEADER = "Idempotent-Replayed";
264
+
265
+ // Delays before sending a keyed write again when its answer was lost.
266
+ export const UNKNOWN_RETRY_DELAYS_MS = [1000, 3000, 9000];
267
+
268
+ /**
269
+ * Whether `error` leaves the outcome unknown: no response came (status 0),
270
+ * or a proxy answered for the server (502, 504). The write may have landed.
271
+ */
272
+ export function isUnknownOutcome(error) {
273
+ const status = error?.status;
274
+ return status === 0 || status === 502 || status === 504;
275
+ }
276
+
277
+ /**
278
+ * Run `attempt`, sending it again after each delay in `delaysMs` while it
279
+ * fails with an unknown outcome. Only for a keyed write, whose resend is
280
+ * answered from the first send when that one landed. Not when the browser
281
+ * says it is offline: then the request never left, and the caller waits for
282
+ * the network instead. Full jitter, as retryWhileBusy.
283
+ */
284
+ export async function retryUnknown(
285
+ attempt,
286
+ { delaysMs = UNKNOWN_RETRY_DELAYS_MS, onRetry } = {},
287
+ ) {
288
+ for (let i = 0; ; i += 1) {
289
+ try {
290
+ return await attempt();
291
+ } catch (error) {
292
+ if (!isUnknownOutcome(error) || error.offline || i >= delaysMs.length) {
293
+ throw error;
294
+ }
295
+ const delay = Math.round(delaysMs[i] * (0.5 + Math.random()));
296
+ onRetry?.({ attempt: i + 1, retries: delaysMs.length, delay, error });
297
+ await new Promise((resolve) => setTimeout(resolve, delay));
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * The key for the next keyed request, and the strict-mode stamp it must
304
+ * claim when an earlier run of its operation already sent it. `joins` says
305
+ * whether the request joins the open logical operation.
306
+ * Returns `{ key, pin, record }`: `pin` is undefined when nothing is pinned,
307
+ * and `record(version)` pins what this request claimed, the first time.
308
+ */
309
+ export function nextIdempotencyKey(client, joins) {
310
+ const frame = joins ? client.operationGroup?.frames.at(-1) : null;
311
+ const keys = frame?.keys;
312
+ if (!keys) return { key: uuidv7(), pin: undefined, record: () => {} };
313
+ const n = frame.count;
314
+ frame.count += 1;
315
+ const pinned = keys.stamps.has(n);
316
+ return {
317
+ key: `${keys.seed}.${n}`,
318
+ pin: pinned ? keys.stamps.get(n) : undefined,
319
+ record: (version) => {
320
+ if (!pinned) keys.stamps.set(n, version ?? null);
321
+ },
322
+ };
323
+ }
324
+
211
325
  /**
212
326
  * Build a fetch AbortSignal that fires after `timeout` ms, or undefined when
213
327
  * timeouts are disabled / unsupported.
@@ -324,7 +438,9 @@ export function xhrSend(
324
438
  * (`makeRequest`) and the batch path (`queueRequest`), so a queued op is
325
439
  * exactly the request that would have gone out. Strict mode stamps every
326
440
  * write, queued or not, and `stampedDocument` names the document it stamped
327
- * for (null when it stamped nothing).
441
+ * for (null when it stamped nothing). `stampedGroup` is the open logical
442
+ * operation it joined, if any, for `makeRequest` to mark written once the
443
+ * server has taken the request.
328
444
  */
329
445
  export function prepareRequest(client, method, path, options = {}) {
330
446
  const {
@@ -335,6 +451,8 @@ export function prepareRequest(client, method, path, options = {}) {
335
451
  outOfBand,
336
452
  noOperation,
337
453
  auditMessage,
454
+ pinnedVersion,
455
+ versioned,
338
456
  } = options;
339
457
 
340
458
  // A write must not go out on a lock that lapsed. `documents.locked()`
@@ -383,15 +501,31 @@ export function prepareRequest(client, method, path, options = {}) {
383
501
  // whenever that write was one the route ignores, such as a vocabulary
384
502
  // entry's metadata, which is exactly what the igt editor queues first.
385
503
  // `stampedDocument` names the document the stamp is for, so a batch split
386
- // into several requests can restamp its later ones (see submitBatch).
504
+ // into several requests can restamp its later ones (see submitBatch). An
505
+ // out-of-band signal (a lock, a service's progress, a query) is no write of
506
+ // the document and carries none.
507
+ // A resend of an operation's request claims what its first run claimed
508
+ // (`pinnedVersion`, null for no claim), so the two are the same request.
387
509
  let stampedDocument = null;
388
- if (client.strictModeDocumentId && method !== "GET") {
510
+ let stampedVersion = null;
511
+ // A write with its own precondition (`versioned: false`, a text edit sent
512
+ // with the digest of the body it was made on) goes without the stamp.
513
+ if (
514
+ client.strictModeDocumentId &&
515
+ method !== "GET" &&
516
+ !outOfBand &&
517
+ versioned !== false
518
+ ) {
389
519
  const docId = client.strictModeDocumentId;
390
- if (client.documentVersions[docId]) {
391
- const docVersion = client.documentVersions[docId];
520
+ const docVersion =
521
+ pinnedVersion !== undefined
522
+ ? pinnedVersion
523
+ : client.documentVersions[docId];
524
+ if (docVersion) {
392
525
  const separator = url.includes("?") ? "&" : "?";
393
526
  url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
394
527
  stampedDocument = docId;
528
+ stampedVersion = docVersion;
395
529
  }
396
530
  }
397
531
 
@@ -406,13 +540,16 @@ export function prepareRequest(client, method, path, options = {}) {
406
540
 
407
541
  // Logical-operation group (see client.beginOperation): stamp every write
408
542
  // with the group id; the message rides along too so the server can label
409
- // the group lazily on whichever tagged write lands first.
543
+ // the group lazily on whichever tagged write lands first. The group counts
544
+ // as written only once such a write has been taken (`makeRequest`): a group
545
+ // whose only write was refused was never made, and relabelling it 404s.
410
546
  //
411
547
  // An out-of-band signal is not one of those writes (see the note at the top
412
548
  // of this file): it never lands in the audit log, so a stamp does nothing
413
549
  // server-side while `written` promises a group that will never exist, and
414
550
  // the relabel PATCH then 404s. A broadcast message (`noOperation`) is
415
551
  // never audited either.
552
+ let stampedGroup = null;
416
553
  if (
417
554
  client.operationGroup &&
418
555
  method !== "GET" &&
@@ -426,10 +563,34 @@ export function prepareRequest(client, method, path, options = {}) {
426
563
  url += `&group-message=${encodeURIComponent(group.message)}`;
427
564
  if (group.kind) url += `&group-kind=${encodeURIComponent(group.kind)}`;
428
565
  if (group.ref) url += `&group-ref=${encodeURIComponent(group.ref)}`;
429
- group.written = true;
566
+ stampedGroup = group;
430
567
  }
431
568
 
432
- return { url, requestBody, stampedDocument };
569
+ return { url, requestBody, stampedDocument, stampedVersion, stampedGroup };
570
+ }
571
+
572
+ /**
573
+ * Whether a write joins the open logical operation (see prepareRequest).
574
+ */
575
+ export function joinsOperation(client, method, options = {}) {
576
+ return Boolean(
577
+ client.operationGroup &&
578
+ method !== "GET" &&
579
+ !options.outOfBand &&
580
+ !options.noOperation,
581
+ );
582
+ }
583
+
584
+ /**
585
+ * Whether a call made on the client goes out with an Idempotency-Key.
586
+ */
587
+ export function takesIdempotencyKey(method, options = {}) {
588
+ return (
589
+ method !== "GET" &&
590
+ !options.outOfBand &&
591
+ !options.noBatch &&
592
+ !options.noIdempotency
593
+ );
433
594
  }
434
595
 
435
596
  /**
@@ -513,7 +674,7 @@ export async function queueRequest(batch, method, path, options = {}) {
513
674
  if (options.noBatch) {
514
675
  throw new Error(`This endpoint cannot be used in a batch: ${path}`);
515
676
  }
516
- const { url, requestBody, stampedDocument } = prepareRequest(
677
+ const { url, requestBody, stampedDocument, stampedGroup } = prepareRequest(
517
678
  batch.client,
518
679
  method,
519
680
  path,
@@ -524,10 +685,15 @@ export async function queueRequest(batch, method, path, options = {}) {
524
685
  method: method.toUpperCase(),
525
686
  };
526
687
  if (requestBody !== undefined) {
527
- operation.body = requestBody;
688
+ // Each b.ref() in the body goes beside it, as the path to a null the
689
+ // server fills with the id (batchRef.js).
690
+ const { body, refs } = takeRefs(batch, requestBody);
691
+ operation.body = body;
692
+ if (refs) operation.refs = refs;
528
693
  }
529
694
  batch.operations.push(operation);
530
695
  batch.stampedDocuments.push(stampedDocument);
696
+ batch.stampedGroups.push(stampedGroup);
531
697
  return { batched: true };
532
698
  }
533
699
 
@@ -549,6 +715,9 @@ export async function queueRequest(batch, method, path, options = {}) {
549
715
  * noOperation - If true, the call never joins an open logical operation
550
716
  * but still queues on a batch like a write. For a
551
717
  * broadcast message, which is never audited.
718
+ * versioned - If false, strict mode does not stamp the call with the
719
+ * document version. For a write that carries its own
720
+ * precondition (a text edit with `base`).
552
721
  * skipResponseTransform - Return raw parsed JSON (no transformResponse)
553
722
  * noAuth - Skip Authorization header
554
723
  * binaryResponse - Return arrayBuffer instead of JSON/text
@@ -574,7 +743,20 @@ export async function makeRequest(client, method, path, options = {}) {
574
743
  if (method !== "GET" && omittedStrictDocument(client)) {
575
744
  await learnOmittedVersion(client);
576
745
  }
577
- const { url, requestBody } = prepareRequest(client, method, path, options);
746
+ const keyed = takesIdempotencyKey(method, options);
747
+ const joins = joinsOperation(client, method, options);
748
+ // The ids the joined operation minted for what it creates (withOperation's
749
+ // `minted`): a create refused 409 id-taken for one of them was made by an
750
+ // earlier send of this operation, and answers as made.
751
+ const minted = joins ? client.operationGroup?.frames.at(-1)?.minted : null;
752
+ const { key, pin, record } = keyed ? nextIdempotencyKey(client, joins) : {};
753
+ const { url, requestBody, stampedGroup, stampedVersion } = prepareRequest(
754
+ client,
755
+ method,
756
+ path,
757
+ keyed ? { ...options, pinnedVersion: pin } : options,
758
+ );
759
+ if (keyed) record(stampedVersion);
578
760
 
579
761
  // Build fetch options
580
762
  const headers = {};
@@ -584,6 +766,7 @@ export async function makeRequest(client, method, path, options = {}) {
584
766
  if (!formData) {
585
767
  headers["Content-Type"] = "application/json";
586
768
  }
769
+ if (keyed) headers[IDEMPOTENCY_HEADER] = key;
587
770
 
588
771
  const fetchOptions = { method, headers };
589
772
  if (requestBody !== undefined) {
@@ -604,25 +787,48 @@ export async function makeRequest(client, method, path, options = {}) {
604
787
  return fetch(url, signal ? { ...fetchOptions, signal } : fetchOptions);
605
788
  };
606
789
 
790
+ // One attempt: a 503, and for a keyed write a 502 or 504, surfaces as a
791
+ // throw so the retries below can see it. Other failures are thrown from
792
+ // the checks after it.
793
+ const attempt = async () => {
794
+ let res;
795
+ try {
796
+ res = await send();
797
+ } catch (e) {
798
+ throw makeNetworkError(e, url, method);
799
+ }
800
+ if (
801
+ res.status === 503 ||
802
+ (keyed && (res.status === 502 || res.status === 504))
803
+ ) {
804
+ throw makeHttpError(res, await parseErrorBody(res), url, method);
805
+ }
806
+ return res;
807
+ };
808
+
607
809
  try {
608
- const response = await retryWhileBusy(async () => {
609
- const res = await send();
610
- // Surface a 503 as a throw so retryWhileBusy can see it; other failures
611
- // are re-thrown from here and handled by the caller below.
612
- if (res.status === 503) {
613
- throw makeHttpError(res, await parseErrorBody(res), url, method);
614
- }
615
- return res;
616
- });
810
+ const response = keyed
811
+ ? await retryUnknown(() => retryWhileBusy(attempt), {
812
+ delaysMs: client.retryDelaysMs,
813
+ })
814
+ : await retryWhileBusy(attempt);
617
815
  noteServerClock(client, response.headers);
618
816
 
619
817
  if (!response.ok) {
620
- const error = makeHttpError(
621
- response,
622
- await parseErrorBody(response),
623
- url,
624
- method,
625
- );
818
+ const errorData = await parseErrorBody(response);
819
+ // Only a single create: a bulk refused whole made none of its rows.
820
+ // Never a deleted row: there is nothing to open.
821
+ if (
822
+ response.status === 409 &&
823
+ errorData?.error === "id-taken" &&
824
+ !errorData.deleted &&
825
+ minted?.has(errorData.id) &&
826
+ requestBody?.id === errorData.id
827
+ ) {
828
+ if (stampedGroup) stampedGroup.written = true;
829
+ return transformResponse({ id: errorData.id });
830
+ }
831
+ const error = makeHttpError(response, errorData, url, method);
626
832
  // 401 means the token is missing/expired/invalid. Fire the app's auth-error
627
833
  // handler once (it discards the token and routes back to login). 403
628
834
  // (forbidden — authenticated but not permitted) deliberately does NOT.
@@ -640,6 +846,8 @@ export async function makeRequest(client, method, path, options = {}) {
640
846
  }
641
847
  throw error;
642
848
  }
849
+ // The server took it, so the operation's group exists.
850
+ if (stampedGroup) stampedGroup.written = true;
643
851
 
644
852
  // Binary response (getMedia)
645
853
  if (binaryResponse) {
@@ -656,19 +864,19 @@ export async function makeRequest(client, method, path, options = {}) {
656
864
  historical: /[?&]as-of=/.test(url),
657
865
  });
658
866
  if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
659
- if (skipResponseTransform) {
660
- return data;
661
- }
662
- return transformResponse(data);
867
+ const answer = skipResponseTransform ? data : transformResponse(data);
868
+ return response.headers.get(REPLAYED_HEADER) === "true"
869
+ ? markReplayed(answer)
870
+ : answer;
663
871
  } else {
664
872
  extractDocumentVersions(client, response.headers);
665
873
  if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
666
874
  return await response.text();
667
875
  }
668
876
  } catch (error) {
669
- if (error.status !== undefined) {
670
- throw error;
671
- }
672
- throw makeNetworkError(error, url, method);
877
+ const escaped =
878
+ error.status !== undefined ? error : makeNetworkError(error, url, method);
879
+ if (keyed) escaped.idempotencyKey = key;
880
+ throw escaped;
673
881
  }
674
882
  }
package/src/ids.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Ids a client mints for what it creates.
3
+ *
4
+ * A create may name the id of the row it makes, so a create sent again after
5
+ * its answer was lost lands under the same id or is told the id is taken
6
+ * (409 with `id-taken`). The server takes only a UUIDv7 (RFC 9562), shaped
7
+ * as its own ids are: a 48-bit millisecond timestamp, a 12-bit counter
8
+ * within the millisecond, then 62 random bits. Reads are id-ordered, so ids
9
+ * minted in one millisecond must still sort in the order they were made.
10
+ */
11
+
12
+ let lastMs = 0;
13
+ let counter = 0;
14
+
15
+ const hex = Array.from({ length: 256 }, (_, i) =>
16
+ i.toString(16).padStart(2, "0"),
17
+ );
18
+
19
+ /**
20
+ * A fresh UUIDv7, later than every one this page minted before it.
21
+ * @returns {string}
22
+ */
23
+ export function uuidv7() {
24
+ const now = Date.now();
25
+ if (now > lastMs) {
26
+ lastMs = now;
27
+ counter = 0;
28
+ } else if (counter < 0xfff) {
29
+ counter += 1;
30
+ } else {
31
+ // This millisecond is full: take the next one rather than repeat an order.
32
+ lastMs += 1;
33
+ counter = 0;
34
+ }
35
+ const b = new Uint8Array(16);
36
+ crypto.getRandomValues(b);
37
+ const high = Math.floor(lastMs / 0x100000000);
38
+ const low = lastMs >>> 0;
39
+ b[0] = (high >>> 8) & 0xff;
40
+ b[1] = high & 0xff;
41
+ b[2] = (low >>> 24) & 0xff;
42
+ b[3] = (low >>> 16) & 0xff;
43
+ b[4] = (low >>> 8) & 0xff;
44
+ b[5] = low & 0xff;
45
+ b[6] = 0x70 | (counter >>> 8);
46
+ b[7] = counter & 0xff;
47
+ b[8] = (b[8] & 0x3f) | 0x80;
48
+ let s = "";
49
+ for (let i = 0; i < 16; i++) {
50
+ s += hex[b[i]];
51
+ if (i === 3 || i === 5 || i === 7 || i === 9) s += "-";
52
+ }
53
+ return s;
54
+ }