@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/index.d.ts +262 -14
- package/package.json +1 -1
- package/src/batchRef.js +128 -0
- package/src/constraints.js +84 -0
- package/src/documentLock.js +67 -29
- package/src/events.js +21 -11
- package/src/http.js +249 -41
- package/src/ids.js +54 -0
- package/src/index.js +540 -135
- package/src/replayed.js +27 -0
- package/src/roles.js +17 -0
- package/src/services.js +49 -1
- package/src/textEdits.js +191 -0
- package/src/transforms.js +4 -0
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
|
-
|
|
109
|
-
|
|
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 =
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
throw
|
|
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
|
+
}
|