@larc-iu/plaid-client 0.2.0-alpha.3 → 0.2.0-alpha.30
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/README.md +39 -0
- package/package.json +13 -6
- package/src/apiAnnotations.d.ts +508 -0
- package/src/apiAnnotations.js +543 -0
- package/src/apiDocuments.d.ts +569 -0
- package/src/apiDocuments.js +551 -0
- package/src/apiLayers.d.ts +369 -0
- package/src/apiLayers.js +222 -0
- package/src/apiMessages.d.ts +293 -0
- package/src/apiMessages.js +265 -0
- package/src/apiPeople.d.ts +626 -0
- package/src/apiPeople.js +698 -0
- package/src/apiProject.d.ts +761 -0
- package/src/apiProject.js +774 -0
- package/src/apiServer.d.ts +455 -0
- package/src/apiServer.js +422 -0
- package/src/apiVocab.d.ts +456 -0
- package/src/apiVocab.js +425 -0
- package/src/batch.d.ts +54 -0
- package/src/batch.js +323 -0
- package/src/batchRef.d.ts +30 -0
- package/src/batchRef.js +128 -0
- package/src/client.d.ts +1321 -0
- package/src/client.js +743 -0
- package/src/codepoint.d.ts +94 -0
- package/src/codepoint.js +131 -1
- package/src/constraints.d.ts +85 -0
- package/src/constraints.js +112 -0
- package/src/created.d.ts +2 -0
- package/src/created.js +29 -0
- package/src/documentLock.d.ts +172 -0
- package/src/documentLock.js +384 -0
- package/src/entryForms.d.ts +63 -0
- package/src/entryForms.js +152 -0
- package/src/events.d.ts +60 -0
- package/src/events.js +271 -0
- package/src/families.d.ts +349 -0
- package/src/families.js +396 -0
- package/src/http.d.ts +233 -0
- package/src/http.js +809 -93
- package/src/ids.d.ts +26 -0
- package/src/ids.js +96 -0
- package/src/index.d.ts +101 -0
- package/src/index.js +183 -2105
- package/src/metadataOps.d.ts +15 -0
- package/src/metadataOps.js +124 -0
- package/src/operations.d.ts +36 -0
- package/src/operations.js +164 -0
- package/src/pagination.d.ts +49 -0
- package/src/pagination.js +20 -53
- package/src/provenance.d.ts +243 -0
- package/src/provenance.js +458 -36
- package/src/replayed.d.ts +3 -0
- package/src/replayed.js +31 -0
- package/src/requester.d.ts +60 -0
- package/src/requester.js +74 -0
- package/src/roles.d.ts +104 -0
- package/src/roles.js +64 -2
- package/src/serviceSchema.d.ts +337 -0
- package/src/serviceSchema.js +177 -60
- package/src/services.d.ts +319 -0
- package/src/services.js +742 -58
- package/src/sse.d.ts +30 -0
- package/src/sse.js +80 -3
- package/src/testing.d.ts +128 -0
- package/src/testing.js +379 -0
- package/src/textEdits.d.ts +59 -0
- package/src/textEdits.js +234 -0
- package/src/transforms.d.ts +25 -0
- package/src/transforms.js +11 -1
- package/src/types.d.ts +867 -0
- package/src/types.js +663 -0
- package/index.d.ts +0 -532
package/src/http.js
CHANGED
|
@@ -1,35 +1,172 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { markReplayed } from "./replayed.js";
|
|
2
|
+
import { transformRequest, transformResponse } from "./transforms.js";
|
|
3
|
+
import { nextIdempotencyKey } from "./operations.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Batches: which calls a batch carries, and which go over the wire anyway.
|
|
7
|
+
//
|
|
8
|
+
// A batch is an OBJECT (`client.batch()`, or the argument `client.batched(fn)`
|
|
9
|
+
// hands `fn`), a view of the client with the same bundles. A write made on the
|
|
10
|
+
// batch queues; a call made on the client itself always goes over the wire,
|
|
11
|
+
// whatever batches happen to be open. So the editor, the importer and the
|
|
12
|
+
// app's chrome can share one client, and a write by code that knows nothing
|
|
13
|
+
// about a batch can never land inside it. Every call reaching this layer is
|
|
14
|
+
// one of three things, and the third column is what a new endpoint has to
|
|
15
|
+
// pick.
|
|
16
|
+
//
|
|
17
|
+
// 1. A WRITE of project data: an entity, its metadata, a layer, a membership,
|
|
18
|
+
// a comment. This is what a batch is for, so on a batch it QUEUES. It is
|
|
19
|
+
// the default and needs no flag.
|
|
20
|
+
//
|
|
21
|
+
// 2. A READ. Every GET is answered from the wire even when made on a batch:
|
|
22
|
+
// the caller gets data rather than `{ batched: true }`, it takes no slot in
|
|
23
|
+
// the batch's results, and server-side it runs against the pool rather than
|
|
24
|
+
// the batch's transaction connection. A read that travels as a POST
|
|
25
|
+
// (`query`) says so with `outOfBand`.
|
|
26
|
+
//
|
|
27
|
+
// 3. An OUT-OF-BAND SIGNAL: stopping a service request, reporting a service's
|
|
28
|
+
// progress or result, taking or dropping a document lock, forgetting a
|
|
29
|
+
// service, an admin action on the server itself. It is shaped like a write
|
|
30
|
+
// but carries no project data, and its whole value is that it happens NOW.
|
|
31
|
+
// Pass `outOfBand`: made on a batch it still goes over the wire, and it
|
|
32
|
+
// never joins an open logical operation. The audit group is a label for
|
|
33
|
+
// the writes, and a signal that is never audited would mark the group
|
|
34
|
+
// written and leave the relabel PATCH 404ing on a group nothing ever
|
|
35
|
+
// created.
|
|
36
|
+
//
|
|
37
|
+
// A broadcast message (`messages.sendMessage`) sits between a write and a
|
|
38
|
+
// signal: it stays batchable, so a message queued after the writes goes out
|
|
39
|
+
// after them, but it writes nothing to the audit log either. `noOperation`
|
|
40
|
+
// keeps it out of an open logical operation without making it out of band.
|
|
41
|
+
//
|
|
42
|
+
// Strict mode stamps the expected document-version onto every write, queued
|
|
43
|
+
// or not. The server checks the first write of a batch request that names the
|
|
44
|
+
// document and skips the rest of that document's, so the bump one op causes
|
|
45
|
+
// does not 409 the next. A batch split past MAX_BATCH_OPS restamps each later
|
|
46
|
+
// request with the version the one before it left (see submitBatch).
|
|
47
|
+
//
|
|
48
|
+
// `noBatch` is not part of that judgment. It marks the calls a batch cannot
|
|
49
|
+
// carry at all (the multipart media and avatar uploads, the user-data store's
|
|
50
|
+
// put and delete, and minting an API token or an invite, whose answer is a
|
|
51
|
+
// secret the server refuses to keep with a keyed batch's answer) and raises
|
|
52
|
+
// so the caller finds out. A
|
|
53
|
+
// batch inside a batch is refused by the batch itself. Never put it on a read: it turns a swallowed read into a
|
|
54
|
+
// thrown one, which is what the chrome hit when an unrelated import was
|
|
55
|
+
// running. A blobless DELETE beside an upload is not one of them: it carries
|
|
56
|
+
// nothing the transport cannot express, so it takes its class from the three
|
|
57
|
+
// above like anything else.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
2
59
|
|
|
3
60
|
// Default per-request timeout (ms). Applied to every request unless the client
|
|
4
61
|
// is constructed with a different `timeout` (0 / null disables it). Note: this
|
|
5
62
|
// also bounds media up/downloads — bump it (or disable) for very large files.
|
|
6
63
|
export const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
64
|
|
|
65
|
+
// Batch submissions get their own, longer budget. A batch runs as ONE server
|
|
66
|
+
// transaction holding the single SQLite write lock for its whole duration, and
|
|
67
|
+
// aborting the HTTP request does not cancel it — so a short timeout buys
|
|
68
|
+
// nothing and costs a retry stacked on a write that is still running.
|
|
69
|
+
export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
|
|
70
|
+
|
|
71
|
+
// A query may run for core's own query limit (30 s, `*query-timeout-ms*` in
|
|
72
|
+
// plaid.sql.query.exec), and core then answers 408. The client waits a little
|
|
73
|
+
// longer than that, so the caller gets core's 408 for a query too broad and
|
|
74
|
+
// not a timeout of its own that reads like a lost connection.
|
|
75
|
+
export const DEFAULT_QUERY_TIMEOUT_MS = 35000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The timeout for a query: the client's own, raised to
|
|
79
|
+
* DEFAULT_QUERY_TIMEOUT_MS when it is shorter. A disabled timeout (0, null)
|
|
80
|
+
* stays disabled.
|
|
81
|
+
*/
|
|
82
|
+
export function queryTimeout(client) {
|
|
83
|
+
const t = client.timeout;
|
|
84
|
+
if (!t || t <= 0) return t;
|
|
85
|
+
return Math.max(t, DEFAULT_QUERY_TIMEOUT_MS);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Note how far the server's clock is from this machine's, from a response's
|
|
90
|
+
* Date header, for `client.serverNow()`. A time the server stamped (an audit
|
|
91
|
+
* entry's `ts`) has to be judged against the server's clock: a browser clock
|
|
92
|
+
* minutes off would misjudge it. A response with no readable Date header
|
|
93
|
+
* leaves the last offset as it was.
|
|
94
|
+
*/
|
|
95
|
+
export function noteServerClock(client, headers) {
|
|
96
|
+
const at = Date.parse(headers?.get?.("date") ?? "");
|
|
97
|
+
if (!Number.isNaN(at)) client.serverClockOffsetMs = at - Date.now();
|
|
98
|
+
}
|
|
99
|
+
|
|
8
100
|
/**
|
|
9
101
|
* Extract and update document versions from response headers and body.
|
|
102
|
+
*
|
|
103
|
+
* `historical` marks a read made with `as-of`: its body carries the version
|
|
104
|
+
* the document HAD then, which is not what a strict-mode write must claim
|
|
105
|
+
* next, so the body is ignored and only the header (always the live
|
|
106
|
+
* version) is learned from. Without this, viewing an old state and then
|
|
107
|
+
* writing 409s on a version that stopped being current long ago.
|
|
10
108
|
*/
|
|
11
|
-
export function extractDocumentVersions(
|
|
12
|
-
|
|
109
|
+
export function extractDocumentVersions(
|
|
110
|
+
client,
|
|
111
|
+
responseHeaders,
|
|
112
|
+
responseBody = null,
|
|
113
|
+
{ historical = false } = {},
|
|
114
|
+
) {
|
|
115
|
+
// A replayed answer (see the Idempotency-Key note below) carries the
|
|
116
|
+
// versions right after the first send, and this page may have read newer
|
|
117
|
+
// ones since. Keep the higher of each.
|
|
118
|
+
const replayed = responseHeaders.get(REPLAYED_HEADER) === "true";
|
|
119
|
+
// Past fifty documents the server leaves the list out and says how many it
|
|
120
|
+
// left out. Any version held may be one of them, so all are forgotten, and
|
|
121
|
+
// makeRequest reads the strict-mode document's again (learnOmittedVersion).
|
|
122
|
+
if (responseHeaders.get("X-Document-Versions-Omitted")) {
|
|
123
|
+
client.documentVersions = {};
|
|
124
|
+
client.documentVersionsOmitted = true;
|
|
125
|
+
}
|
|
126
|
+
const docVersionsHeader = responseHeaders.get("X-Document-Versions");
|
|
13
127
|
if (docVersionsHeader) {
|
|
14
128
|
try {
|
|
15
129
|
const versionsMap = JSON.parse(docVersionsHeader);
|
|
16
|
-
if (typeof versionsMap ===
|
|
130
|
+
if (typeof versionsMap === "object" && versionsMap !== null) {
|
|
17
131
|
// Clone once, then assign — cloning inside the loop is O(n²) and pointless.
|
|
18
|
-
client.documentVersions =
|
|
132
|
+
client.documentVersions = mergeVersions(
|
|
133
|
+
client.documentVersions,
|
|
134
|
+
versionsMap,
|
|
135
|
+
replayed,
|
|
136
|
+
);
|
|
19
137
|
}
|
|
20
138
|
} catch (e) {
|
|
21
|
-
console.warn(
|
|
139
|
+
console.warn("Failed to parse document versions header:", e);
|
|
22
140
|
}
|
|
23
141
|
}
|
|
24
142
|
|
|
25
|
-
if (responseBody && typeof responseBody ===
|
|
26
|
-
if (responseBody[
|
|
27
|
-
client.documentVersions =
|
|
28
|
-
|
|
143
|
+
if (!historical && responseBody && typeof responseBody === "object") {
|
|
144
|
+
if (responseBody["document/id"] && responseBody["document/version"]) {
|
|
145
|
+
client.documentVersions = mergeVersions(
|
|
146
|
+
client.documentVersions,
|
|
147
|
+
{ [responseBody["document/id"]]: responseBody["document/version"] },
|
|
148
|
+
replayed,
|
|
149
|
+
);
|
|
29
150
|
}
|
|
30
151
|
}
|
|
31
152
|
}
|
|
32
153
|
|
|
154
|
+
/**
|
|
155
|
+
* `held` with `learned` merged in. A replayed answer only raises a version:
|
|
156
|
+
* it is what the document was right after the first send.
|
|
157
|
+
*/
|
|
158
|
+
export function mergeVersions(held, learned, replayed = false) {
|
|
159
|
+
if (!replayed) return { ...held, ...learned };
|
|
160
|
+
const merged = { ...held };
|
|
161
|
+
for (const [docId, version] of Object.entries(learned)) {
|
|
162
|
+
const current = merged[docId];
|
|
163
|
+
if (!(typeof current === "number" && current >= version)) {
|
|
164
|
+
merged[docId] = version;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
|
|
33
170
|
/**
|
|
34
171
|
* Read a failed response's body as parsed JSON, falling back to text.
|
|
35
172
|
*/
|
|
@@ -37,7 +174,11 @@ export async function parseErrorBody(response) {
|
|
|
37
174
|
try {
|
|
38
175
|
return await response.json();
|
|
39
176
|
} catch (_) {
|
|
40
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
message: await response
|
|
179
|
+
.text()
|
|
180
|
+
.catch(() => "Unable to read error response"),
|
|
181
|
+
};
|
|
41
182
|
}
|
|
42
183
|
}
|
|
43
184
|
|
|
@@ -45,7 +186,11 @@ export async function parseErrorBody(response) {
|
|
|
45
186
|
* Create an enriched error from a failed HTTP response.
|
|
46
187
|
*/
|
|
47
188
|
export function makeHttpError(response, errorData, url, method) {
|
|
48
|
-
const serverMessage =
|
|
189
|
+
const serverMessage =
|
|
190
|
+
errorData?.error ||
|
|
191
|
+
errorData?.message ||
|
|
192
|
+
response.statusText ||
|
|
193
|
+
"Unknown error";
|
|
49
194
|
const error = new Error(`HTTP ${response.status} ${serverMessage} at ${url}`);
|
|
50
195
|
error.status = response.status;
|
|
51
196
|
error.statusText = response.statusText;
|
|
@@ -59,7 +204,9 @@ export function makeHttpError(response, errorData, url, method) {
|
|
|
59
204
|
* Create a network error (status 0). Timeout aborts get a clearer message.
|
|
60
205
|
*/
|
|
61
206
|
export function makeNetworkError(originalError, url, method) {
|
|
62
|
-
const timedOut =
|
|
207
|
+
const timedOut =
|
|
208
|
+
originalError?.name === "TimeoutError" ||
|
|
209
|
+
originalError?.name === "AbortError";
|
|
63
210
|
const message = timedOut
|
|
64
211
|
? `Request timed out at ${url}`
|
|
65
212
|
: `Network error: ${originalError.message} at ${url}`;
|
|
@@ -68,50 +215,274 @@ export function makeNetworkError(originalError, url, method) {
|
|
|
68
215
|
error.url = url;
|
|
69
216
|
error.method = method;
|
|
70
217
|
error.originalError = originalError;
|
|
218
|
+
// The browser says it has no network, so the request most likely never
|
|
219
|
+
// left this machine. A timeout is not that: the request went, and may land.
|
|
220
|
+
if (!timedOut && globalThis.navigator?.onLine === false) error.offline = true;
|
|
71
221
|
return error;
|
|
72
222
|
}
|
|
73
223
|
|
|
224
|
+
// Retry budget for 503 "Database busy" responses. Plaid serializes writers on
|
|
225
|
+
// a single SQLite write lock; a writer that can't get it within the server's
|
|
226
|
+
// busy_timeout is refused with 503. That refusal is definitive — the
|
|
227
|
+
// transaction never opened, or was rolled back whole — so repeating the
|
|
228
|
+
// request is safe, including for a batch (which is all-or-nothing by
|
|
229
|
+
// construction). Retrying here is what lets a long import ride out someone
|
|
230
|
+
// else's slow write instead of dying partway through.
|
|
231
|
+
export const BUSY_RETRIES = 4;
|
|
232
|
+
export const BUSY_BACKOFF_MS = 250;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Run `attempt`, retrying while it rejects with a 503. Exponential backoff
|
|
236
|
+
* with full jitter, so two clients that collide don't march in lockstep and
|
|
237
|
+
* collide again on every retry.
|
|
238
|
+
*
|
|
239
|
+
* `attempt` must perform the whole request, not just await a prepared one:
|
|
240
|
+
* a fetch body and an AbortSignal.timeout are both single-use.
|
|
241
|
+
* @param {() => Promise<any>} attempt
|
|
242
|
+
* @param {{retries?: number, baseDelayMs?: number, onRetry?: (info: object) => void}} [options]
|
|
243
|
+
*/
|
|
244
|
+
export async function retryWhileBusy(
|
|
245
|
+
attempt,
|
|
246
|
+
{ retries = BUSY_RETRIES, baseDelayMs = BUSY_BACKOFF_MS, onRetry } = {},
|
|
247
|
+
) {
|
|
248
|
+
for (let i = 0; ; i += 1) {
|
|
249
|
+
try {
|
|
250
|
+
return await attempt();
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error?.status !== 503 || i >= retries) throw error;
|
|
253
|
+
const delay = Math.round(baseDelayMs * 2 ** i * (0.5 + Math.random()));
|
|
254
|
+
onRetry?.({ attempt: i + 1, retries, delay, error });
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// The Idempotency-Key.
|
|
262
|
+
//
|
|
263
|
+
// Every write that is not a signal (`outOfBand`) or a call a batch cannot
|
|
264
|
+
// carry (`noBatch`: an upload, user data, a call that mints a secret) goes out
|
|
265
|
+
// with an `Idempotency-Key` header, and so does every batch request (a queued
|
|
266
|
+
// op has none: its batch request carries one). The server keeps the answer to a
|
|
267
|
+
// keyed write for a day, and the same key sent again for the same request is
|
|
268
|
+
// answered from it with `Idempotent-Replayed: true`, writing nothing. So a
|
|
269
|
+
// write whose answer was lost (no response, 502, 504) can be sent again
|
|
270
|
+
// safely: it lands once. `makeRequest` and `postBatch` do that three times
|
|
271
|
+
// themselves (retryUnknown), and the error that escapes carries the key as
|
|
272
|
+
// `idempotencyKey`.
|
|
273
|
+
//
|
|
274
|
+
// Inside a logical operation opened with `keys` (client.keySeed()), the nth
|
|
275
|
+
// keyed request that joins it takes the key `<seed>.<n>` and, from its first
|
|
276
|
+
// run, a pinned strict-mode stamp, so running the operation again from the
|
|
277
|
+
// top sends byte for byte the requests the first run sent: those that landed
|
|
278
|
+
// are replayed and the rest run. A request that does not join the operation
|
|
279
|
+
// mints its own key.
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
export const IDEMPOTENCY_HEADER = "Idempotency-Key";
|
|
283
|
+
export const REPLAYED_HEADER = "Idempotent-Replayed";
|
|
284
|
+
|
|
285
|
+
// Delays before sending a keyed write again when its answer was lost.
|
|
286
|
+
export const UNKNOWN_RETRY_DELAYS_MS = [1000, 3000, 9000];
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Whether `error` leaves the outcome unknown: no response came (status 0),
|
|
290
|
+
* or a proxy answered for the server (502, 504). The write may have landed.
|
|
291
|
+
*/
|
|
292
|
+
export function isUnknownOutcome(error) {
|
|
293
|
+
const status = error?.status;
|
|
294
|
+
return status === 0 || status === 502 || status === 504;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Whether a create refused with `status` and body `data` was made by an
|
|
299
|
+
* earlier send of the same work: `409 id-taken` for an id in `minted` (a Set
|
|
300
|
+
* of the ids the work minted for its creates), and not for a row deleted
|
|
301
|
+
* since (there is nothing to go on with). The one rule for a lost answer's
|
|
302
|
+
* create. The Python twin is `minted_taken`.
|
|
303
|
+
*/
|
|
304
|
+
export function mintedTaken(status, data, minted) {
|
|
305
|
+
return (
|
|
306
|
+
status === 409 &&
|
|
307
|
+
data?.error === "id-taken" &&
|
|
308
|
+
!data.deleted &&
|
|
309
|
+
!!minted?.has(data.id)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Run `attempt`, sending it again after each delay in `delaysMs` while it
|
|
315
|
+
* fails with an unknown outcome. Only for a keyed write, whose resend is
|
|
316
|
+
* answered from the first send when that one landed. Not when the browser
|
|
317
|
+
* says it is offline: then the request never left, and the caller waits for
|
|
318
|
+
* the network instead. Full jitter, as retryWhileBusy.
|
|
319
|
+
* @param {() => Promise<any>} attempt
|
|
320
|
+
* @param {{delaysMs?: number[], onRetry?: (info: object) => void}} [options]
|
|
321
|
+
*/
|
|
322
|
+
export async function retryUnknown(
|
|
323
|
+
attempt,
|
|
324
|
+
{ delaysMs = UNKNOWN_RETRY_DELAYS_MS, onRetry } = {},
|
|
325
|
+
) {
|
|
326
|
+
for (let i = 0; ; i += 1) {
|
|
327
|
+
try {
|
|
328
|
+
return await attempt();
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (!isUnknownOutcome(error) || error.offline || i >= delaysMs.length) {
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
const delay = Math.round(delaysMs[i] * (0.5 + Math.random()));
|
|
334
|
+
onRetry?.({ attempt: i + 1, retries: delaysMs.length, delay, error });
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
74
340
|
/**
|
|
75
341
|
* Build a fetch AbortSignal that fires after `timeout` ms, or undefined when
|
|
76
342
|
* timeouts are disabled / unsupported.
|
|
77
343
|
*/
|
|
78
344
|
export function timeoutSignal(timeout) {
|
|
79
|
-
if (
|
|
345
|
+
if (
|
|
346
|
+
timeout &&
|
|
347
|
+
timeout > 0 &&
|
|
348
|
+
typeof AbortSignal !== "undefined" &&
|
|
349
|
+
AbortSignal.timeout
|
|
350
|
+
) {
|
|
80
351
|
return AbortSignal.timeout(timeout);
|
|
81
352
|
}
|
|
82
353
|
return undefined;
|
|
83
354
|
}
|
|
84
355
|
|
|
85
356
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* binaryResponse - Return arrayBuffer instead of JSON/text
|
|
97
|
-
* timeout - Per-request timeout in ms overriding client.timeout
|
|
98
|
-
* for this call (0/null disables). Used for known-long
|
|
99
|
-
* ops like project delete.
|
|
357
|
+
* Send a request through XMLHttpRequest so the caller can watch the bytes go
|
|
358
|
+
* up: fetch cannot report upload progress. Resolves to a Response, so the rest
|
|
359
|
+
* of the pipeline is the same either way. The timeout is a STALL timeout,
|
|
360
|
+
* re-armed by every progress event, so a large file on a slow link is never
|
|
361
|
+
* cut off while it is still moving, only once nothing has moved for `timeout`
|
|
362
|
+
* ms. Rejects the way fetch does: a TypeError for a network failure, an error
|
|
363
|
+
* named TimeoutError for a stall.
|
|
364
|
+
* @param {string} url
|
|
365
|
+
* @param {{method: string, headers: Record<string, string>, body: any}} request
|
|
366
|
+
* @param {{onUploadProgress?: (progress: {loaded: number, total: number | null}) => void, timeout?: number | null}} [options]
|
|
100
367
|
*/
|
|
101
|
-
export
|
|
368
|
+
export function xhrSend(
|
|
369
|
+
url,
|
|
370
|
+
{ method, headers, body },
|
|
371
|
+
{ onUploadProgress, timeout } = {},
|
|
372
|
+
) {
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
const xhr = new XMLHttpRequest();
|
|
375
|
+
xhr.open(method, url);
|
|
376
|
+
xhr.responseType = "arraybuffer";
|
|
377
|
+
for (const [name, value] of Object.entries(headers || {}))
|
|
378
|
+
xhr.setRequestHeader(name, value);
|
|
379
|
+
|
|
380
|
+
let timer = null;
|
|
381
|
+
const disarm = () => clearTimeout(timer);
|
|
382
|
+
const arm = () => {
|
|
383
|
+
if (!timeout || timeout <= 0) return;
|
|
384
|
+
disarm();
|
|
385
|
+
timer = setTimeout(() => {
|
|
386
|
+
xhr.abort();
|
|
387
|
+
reject(
|
|
388
|
+
Object.assign(new Error(`Request timed out at ${url}`), {
|
|
389
|
+
name: "TimeoutError",
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
}, timeout);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
xhr.upload.onprogress = (e) => {
|
|
396
|
+
arm();
|
|
397
|
+
if (onUploadProgress) {
|
|
398
|
+
onUploadProgress({
|
|
399
|
+
loaded: e.loaded,
|
|
400
|
+
total: e.lengthComputable ? e.total : null,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
xhr.onprogress = arm;
|
|
405
|
+
xhr.onload = () => {
|
|
406
|
+
disarm();
|
|
407
|
+
if (xhr.status === 0) {
|
|
408
|
+
reject(new TypeError(`Failed to fetch ${url}`));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const responseHeaders = new Headers();
|
|
412
|
+
for (const line of (xhr.getAllResponseHeaders() || "")
|
|
413
|
+
.trim()
|
|
414
|
+
.split(/\r?\n/)) {
|
|
415
|
+
const i = line.indexOf(":");
|
|
416
|
+
if (i > 0)
|
|
417
|
+
responseHeaders.append(
|
|
418
|
+
line.slice(0, i).trim(),
|
|
419
|
+
line.slice(i + 1).trim(),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
// A Response refuses a body for these statuses.
|
|
423
|
+
const responseBody = [204, 205, 304].includes(xhr.status)
|
|
424
|
+
? null
|
|
425
|
+
: xhr.response;
|
|
426
|
+
resolve(
|
|
427
|
+
new Response(responseBody, {
|
|
428
|
+
status: xhr.status,
|
|
429
|
+
statusText: xhr.statusText,
|
|
430
|
+
headers: responseHeaders,
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
xhr.onerror = () => {
|
|
435
|
+
disarm();
|
|
436
|
+
reject(new TypeError(`Failed to fetch ${url}`));
|
|
437
|
+
};
|
|
438
|
+
xhr.onabort = () => {
|
|
439
|
+
disarm();
|
|
440
|
+
reject(
|
|
441
|
+
Object.assign(new Error(`Request aborted at ${url}`), {
|
|
442
|
+
name: "AbortError",
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
arm();
|
|
448
|
+
xhr.send(body);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Everything a request is before it goes anywhere: the URL with its query
|
|
454
|
+
* params and the stamps strict mode, a per-call audit message and an open
|
|
455
|
+
* logical operation add, plus the transformed body. Shared by the wire path
|
|
456
|
+
* (`makeRequest`) and the batch path (`queueRequest`), so a queued op is
|
|
457
|
+
* exactly the request that would have gone out. Strict mode stamps every
|
|
458
|
+
* write, queued or not, and `stampedDocument` names the document it stamped
|
|
459
|
+
* for (null when it stamped nothing). `stampedGroup` is the open logical
|
|
460
|
+
* operation it joined, if any, for `makeRequest` to mark written once the
|
|
461
|
+
* server has taken the request.
|
|
462
|
+
*/
|
|
463
|
+
export function prepareRequest(client, method, path, options = {}) {
|
|
102
464
|
const {
|
|
103
465
|
body,
|
|
104
466
|
rawBody,
|
|
105
467
|
formData,
|
|
106
468
|
queryParams,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
noAuth,
|
|
110
|
-
binaryResponse,
|
|
469
|
+
outOfBand,
|
|
470
|
+
noOperation,
|
|
111
471
|
auditMessage,
|
|
112
|
-
|
|
472
|
+
pinnedVersion,
|
|
473
|
+
versioned,
|
|
113
474
|
} = options;
|
|
114
475
|
|
|
476
|
+
// A write must not go out on a lock that lapsed. `documents.locked()`
|
|
477
|
+
// records the loss here when its keep-alive cannot renew, and from that
|
|
478
|
+
// moment the block is holding nothing: an edit by somebody else can already
|
|
479
|
+
// have landed between the read the work was planned from and the write about
|
|
480
|
+
// to go out. Reads pass, and so do the lock routes themselves, which is how
|
|
481
|
+
// the block still releases on its way out.
|
|
482
|
+
if (client.documentLockLost && method !== "GET" && !path.endsWith("/lock")) {
|
|
483
|
+
throw client.documentLockLost;
|
|
484
|
+
}
|
|
485
|
+
|
|
115
486
|
// Build URL
|
|
116
487
|
let url = `${client.baseUrl}${path}`;
|
|
117
488
|
|
|
@@ -140,114 +511,459 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
140
511
|
requestBody = transformRequest(body);
|
|
141
512
|
}
|
|
142
513
|
|
|
143
|
-
// Strict mode: append document-version for non-GET requests
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
514
|
+
// Strict mode: append document-version for non-GET requests, on EVERY
|
|
515
|
+
// queued write of a batch. The server validates the first write it can
|
|
516
|
+
// (one whose route resolves a document) and skips the rest of that
|
|
517
|
+
// document's, so the version bump a sub-op causes does not 409 the next
|
|
518
|
+
// one. Stamping only the first write was silently no check at all
|
|
519
|
+
// whenever that write was one the route ignores, such as a vocabulary
|
|
520
|
+
// entry's metadata, which is exactly what the igt editor queues first.
|
|
521
|
+
// `stampedDocument` names the document the stamp is for, so a batch split
|
|
522
|
+
// into several requests can restamp its later ones (see submitBatch). An
|
|
523
|
+
// out-of-band signal (a lock, a service's progress, a query) is no write of
|
|
524
|
+
// the document and carries none.
|
|
525
|
+
// A resend of an operation's request claims what its first run claimed
|
|
526
|
+
// (`pinnedVersion`, null for no claim), so the two are the same request.
|
|
527
|
+
let stampedDocument = null;
|
|
528
|
+
let stampedVersion = null;
|
|
529
|
+
// A write with its own precondition (`versioned: false`, a text edit sent
|
|
530
|
+
// with the digest of the body it was made on) goes without the stamp.
|
|
531
|
+
if (
|
|
532
|
+
client.strictModeDocumentId &&
|
|
533
|
+
method !== "GET" &&
|
|
534
|
+
!outOfBand &&
|
|
535
|
+
versioned !== false
|
|
536
|
+
) {
|
|
151
537
|
const docId = client.strictModeDocumentId;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
538
|
+
const docVersion =
|
|
539
|
+
pinnedVersion !== undefined
|
|
540
|
+
? pinnedVersion
|
|
541
|
+
: client.documentVersions[docId];
|
|
542
|
+
if (docVersion) {
|
|
543
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
155
544
|
url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
|
|
156
|
-
|
|
545
|
+
stampedDocument = docId;
|
|
546
|
+
stampedVersion = docVersion;
|
|
157
547
|
}
|
|
158
548
|
}
|
|
159
549
|
|
|
160
550
|
// Per-call custom audit-log message (overrides the auto-generated
|
|
161
|
-
// description of THIS write)
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const separator = url.includes('?') ? '&' : '?';
|
|
551
|
+
// description of THIS write), stamped on the one write it was given to. The
|
|
552
|
+
// server templates `{param}` placeholders against the endpoint's own
|
|
553
|
+
// path/query/body params.
|
|
554
|
+
if (auditMessage && method !== "GET") {
|
|
555
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
167
556
|
url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
|
|
168
557
|
}
|
|
169
558
|
|
|
170
559
|
// Logical-operation group (see client.beginOperation): stamp every write
|
|
171
560
|
// with the group id; the message rides along too so the server can label
|
|
172
|
-
// the group lazily on whichever tagged write lands first.
|
|
173
|
-
|
|
561
|
+
// the group lazily on whichever tagged write lands first. The group counts
|
|
562
|
+
// as written only once such a write has been taken (`makeRequest`): a group
|
|
563
|
+
// whose only write was refused was never made, and relabelling it 404s.
|
|
564
|
+
//
|
|
565
|
+
// An out-of-band signal is not one of those writes (see the note at the top
|
|
566
|
+
// of this file): it never lands in the audit log, so a stamp does nothing
|
|
567
|
+
// server-side while `written` promises a group that will never exist, and
|
|
568
|
+
// the relabel PATCH then 404s. A broadcast message (`noOperation`) is
|
|
569
|
+
// never audited either.
|
|
570
|
+
let stampedGroup = null;
|
|
571
|
+
if (
|
|
572
|
+
client.operationGroup &&
|
|
573
|
+
method !== "GET" &&
|
|
574
|
+
!outOfBand &&
|
|
575
|
+
!noOperation
|
|
576
|
+
) {
|
|
174
577
|
const group = client.operationGroup;
|
|
175
|
-
const separator = url.includes(
|
|
578
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
176
579
|
url += `${separator}group-id=${encodeURIComponent(group.id)}`;
|
|
177
|
-
if (group.message)
|
|
178
|
-
|
|
580
|
+
if (group.message)
|
|
581
|
+
url += `&group-message=${encodeURIComponent(group.message)}`;
|
|
582
|
+
if (group.kind) url += `&group-kind=${encodeURIComponent(group.kind)}`;
|
|
583
|
+
if (group.ref) url += `&group-ref=${encodeURIComponent(group.ref)}`;
|
|
584
|
+
stampedGroup = group;
|
|
179
585
|
}
|
|
180
586
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
587
|
+
return { url, requestBody, stampedDocument, stampedVersion, stampedGroup };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Whether a write joins the open logical operation (see prepareRequest).
|
|
592
|
+
*/
|
|
593
|
+
export function joinsOperation(client, method, options = {}) {
|
|
594
|
+
return Boolean(
|
|
595
|
+
client.operationGroup &&
|
|
596
|
+
method !== "GET" &&
|
|
597
|
+
!options.outOfBand &&
|
|
598
|
+
!options.noOperation,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Whether a call made on the client goes out with an Idempotency-Key.
|
|
604
|
+
*/
|
|
605
|
+
export function takesIdempotencyKey(method, options = {}) {
|
|
606
|
+
return (
|
|
607
|
+
method !== "GET" &&
|
|
608
|
+
!options.outOfBand &&
|
|
609
|
+
!options.noBatch
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* `path` with its strict-mode `document-version` claim replaced by `version`,
|
|
615
|
+
* or dropped when `version` is unknown. For the later requests of a batch
|
|
616
|
+
* split past MAX_BATCH_OPS: each op was stamped with the version the document
|
|
617
|
+
* had when it was queued, and every request before it has since moved the
|
|
618
|
+
* document on.
|
|
619
|
+
*/
|
|
620
|
+
export function restampDocumentVersion(path, version) {
|
|
621
|
+
const [base, query = ""] = path.split(/\?(.*)/s);
|
|
622
|
+
const params = query
|
|
623
|
+
.split("&")
|
|
624
|
+
.filter((p) => p && !p.startsWith("document-version="));
|
|
625
|
+
if (version !== undefined && version !== null) {
|
|
626
|
+
params.push(`document-version=${encodeURIComponent(version)}`);
|
|
627
|
+
}
|
|
628
|
+
return params.length ? `${base}?${params.join("&")}` : base;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The strict-mode document whose version a response left out
|
|
633
|
+
* (X-Document-Versions-Omitted) and the client has not learned since, or
|
|
634
|
+
* null. Such a write would go out unstamped, which is no check at all.
|
|
635
|
+
*/
|
|
636
|
+
function omittedStrictDocument(client) {
|
|
637
|
+
const docId = client.strictModeDocumentId;
|
|
638
|
+
return docId &&
|
|
639
|
+
client.documentVersionsOmitted &&
|
|
640
|
+
client.documentVersions[docId] === undefined
|
|
641
|
+
? docId
|
|
642
|
+
: null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Read the strict-mode document once to learn the version a response left
|
|
647
|
+
* out. Done right after that response, so the client's own large write is
|
|
648
|
+
* not mistaken for somebody else's edit at the next save, and again before a
|
|
649
|
+
* strict write when that read failed.
|
|
650
|
+
*/
|
|
651
|
+
async function learnOmittedVersion(client) {
|
|
652
|
+
const docId = omittedStrictDocument(client);
|
|
653
|
+
if (!docId) return;
|
|
654
|
+
// One read at a time: a write made while it is in flight waits for it
|
|
655
|
+
// rather than going out unstamped.
|
|
656
|
+
if (!client._learningOmittedVersion) {
|
|
657
|
+
client._learningOmittedVersion = makeRequest(
|
|
658
|
+
client,
|
|
659
|
+
"GET",
|
|
660
|
+
`/api/v1/documents/${docId}`,
|
|
661
|
+
{ learningOmittedVersion: true },
|
|
662
|
+
).finally(() => {
|
|
663
|
+
client._learningOmittedVersion = null;
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
await client._learningOmittedVersion;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function learnOmittedVersionQuietly(client) {
|
|
670
|
+
try {
|
|
671
|
+
await learnOmittedVersion(client);
|
|
672
|
+
} catch (_) {
|
|
673
|
+
/* the write went through; the next strict write asks again */
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* One send of a request by fetch, under a fresh timeout signal: an
|
|
679
|
+
* AbortSignal.timeout fires once and stays aborted, so a retry needs its own.
|
|
680
|
+
* `timeoutMs` 0 or null sends with none.
|
|
681
|
+
*/
|
|
682
|
+
export function fetchWithTimeout(url, fetchOptions, timeoutMs) {
|
|
683
|
+
const signal = timeoutSignal(timeoutMs);
|
|
684
|
+
return fetch(url, signal ? { ...fetchOptions, signal } : fetchOptions);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Run `attempt` with the client's retries: a 503 is retried (unless
|
|
689
|
+
* `noBusyRetry`), and a keyed request whose answer was lost is sent again
|
|
690
|
+
* under its key (retryUnknown). Each retry is told to `onRetry` listeners.
|
|
691
|
+
*/
|
|
692
|
+
export function withRetries(client, attempt, { keyed = false, noBusyRetry = false } = {}) {
|
|
693
|
+
const onRetry = (info) => client._noteRetry?.(info);
|
|
694
|
+
const busy = noBusyRetry ? { onRetry, retries: 0 } : { onRetry };
|
|
695
|
+
return keyed
|
|
696
|
+
? retryUnknown(() => retryWhileBusy(attempt, busy), {
|
|
697
|
+
delaysMs: client.retryDelaysMs,
|
|
698
|
+
onRetry,
|
|
699
|
+
})
|
|
700
|
+
: retryWhileBusy(attempt, busy);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* A 401 means the token is missing, expired or invalid. Fire the app's
|
|
705
|
+
* auth-error handler once per client (it discards the token and routes back
|
|
706
|
+
* to login). A 403 (authenticated but not permitted) deliberately does not.
|
|
707
|
+
*/
|
|
708
|
+
export function noteAuthError(client, error) {
|
|
709
|
+
if (
|
|
710
|
+
error?.status === 401 &&
|
|
711
|
+
typeof client.onAuthError === "function" &&
|
|
712
|
+
!client._authErrorFired
|
|
713
|
+
) {
|
|
714
|
+
client._authErrorFired = true;
|
|
715
|
+
try {
|
|
716
|
+
client.onAuthError(error);
|
|
717
|
+
} catch (_) {
|
|
718
|
+
/* handler must not mask the original error */
|
|
185
719
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Generic request method handling all fetch logic.
|
|
725
|
+
*
|
|
726
|
+
* Options:
|
|
727
|
+
* body - Object body, run through transformRequest
|
|
728
|
+
* rawBody - Body value passed directly (no transform). Mutually exclusive with body.
|
|
729
|
+
* formData - If true, body is FormData; skip Content-Type header
|
|
730
|
+
* queryParams - Object of query param key/values to append
|
|
731
|
+
* noBatch - If true, throw when made on a batch. Only for calls a
|
|
732
|
+
* batch cannot carry at all (see the note at the
|
|
733
|
+
* top of this file); never for a read.
|
|
734
|
+
* outOfBand - If true, the call is a signal rather than a write of
|
|
735
|
+
* project data: made on a batch it still goes over the
|
|
736
|
+
* wire, and it never joins an open logical operation (see
|
|
737
|
+
* the note at the top of this file).
|
|
738
|
+
* noOperation - If true, the call never joins an open logical operation
|
|
739
|
+
* but still queues on a batch like a write. For a
|
|
740
|
+
* broadcast message, which is never audited.
|
|
741
|
+
* versioned - If false, strict mode does not stamp the call with the
|
|
742
|
+
* document version. For a write that carries its own
|
|
743
|
+
* precondition (a text edit with `base`).
|
|
744
|
+
* skipResponseTransform - Return raw parsed JSON (no transformResponse)
|
|
745
|
+
* noAuth - Skip Authorization header
|
|
746
|
+
* binaryResponse - Return arrayBuffer instead of JSON/text
|
|
747
|
+
* blobResponse - Return a Blob of the body, its `type` the response's
|
|
748
|
+
* Content-Type (a recording, whose reader needs both)
|
|
749
|
+
* onDownloadProgress - With blobResponse, called with `{ loaded, total }`
|
|
750
|
+
* (bytes; total null when unknown) as the body arrives
|
|
751
|
+
* noBusyRetry - If true, a 503 is not retried. For a query: core
|
|
752
|
+
* answers 503 when its queue of large queries stays full
|
|
753
|
+
* until the query's deadline, and asking again at once
|
|
754
|
+
* only joins the same queue.
|
|
755
|
+
* timeout - Per-request timeout in ms overriding client.timeout
|
|
756
|
+
* for this call (0/null disables). Used for known-long
|
|
757
|
+
* ops like project delete.
|
|
758
|
+
* onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
|
|
759
|
+
* unknown) as the request body goes up. In a browser the
|
|
760
|
+
* request then travels by XMLHttpRequest (see xhrSend),
|
|
761
|
+
* where the timeout only fires when the upload stalls;
|
|
762
|
+
* elsewhere the callback is ignored and fetch is used.
|
|
763
|
+
*/
|
|
764
|
+
/**
|
|
765
|
+
* What a write about to go out needs to tell the lock keepers it renewed:
|
|
766
|
+
* each held document's keeper, its version now and the send time. Empty when
|
|
767
|
+
* no `documents.locked()` block on `client` keeps a lock. The server renews a
|
|
768
|
+
* live lock on every write its holder makes to the document, so a write that
|
|
769
|
+
* moved the held document's version renewed it (`writesLanded`).
|
|
770
|
+
* @private
|
|
771
|
+
*/
|
|
772
|
+
export function writesStarted(client) {
|
|
773
|
+
const keepers = client.lockKeepers;
|
|
774
|
+
if (!keepers || keepers.size === 0) return [];
|
|
775
|
+
return [...keepers.entries()].map(([docId, keeper]) => [
|
|
776
|
+
keeper,
|
|
777
|
+
client.documentVersions?.[docId],
|
|
778
|
+
keeper.now(),
|
|
779
|
+
]);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The write `started` was read before has been taken, and its answer's
|
|
784
|
+
* versions learned. A held document whose version it moved was written by
|
|
785
|
+
* this run. A replayed answer was not taken again, and renewed nothing.
|
|
786
|
+
* @private
|
|
787
|
+
*/
|
|
788
|
+
export function writesLanded(client, started, replayed = false) {
|
|
789
|
+
if (replayed || !started?.length) return;
|
|
790
|
+
for (const [keeper, before, sent] of started) {
|
|
791
|
+
const after = client.documentVersions?.[keeper.documentId];
|
|
792
|
+
if (before != null && after != null && after !== before) {
|
|
793
|
+
keeper.noteWrite(sent);
|
|
192
794
|
}
|
|
193
|
-
client.batchOperations.push(operation);
|
|
194
|
-
return { batched: true };
|
|
195
795
|
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* A response's body as a Blob typed by its Content-Type, `onProgress` told
|
|
800
|
+
* `{ loaded, total }` after each piece when given (total null when the
|
|
801
|
+
* response has no Content-Length).
|
|
802
|
+
* @private
|
|
803
|
+
*/
|
|
804
|
+
async function readBlob(response, onProgress) {
|
|
805
|
+
const type = response.headers.get("content-type") ?? "";
|
|
806
|
+
const reader = onProgress ? response.body?.getReader?.() : null;
|
|
807
|
+
if (!reader) return new Blob([await response.arrayBuffer()], { type });
|
|
808
|
+
const length = Number(response.headers.get("content-length"));
|
|
809
|
+
const total = length > 0 ? length : null;
|
|
810
|
+
const parts = [];
|
|
811
|
+
let loaded = 0;
|
|
812
|
+
for (;;) {
|
|
813
|
+
const { done, value } = await reader.read();
|
|
814
|
+
if (done) break;
|
|
815
|
+
parts.push(value);
|
|
816
|
+
loaded += value.byteLength;
|
|
817
|
+
onProgress({ loaded, total });
|
|
818
|
+
}
|
|
819
|
+
return new Blob(parts, { type });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export async function makeRequest(client, method, path, options = {}) {
|
|
823
|
+
const {
|
|
824
|
+
formData,
|
|
825
|
+
skipResponseTransform,
|
|
826
|
+
noAuth,
|
|
827
|
+
binaryResponse,
|
|
828
|
+
blobResponse,
|
|
829
|
+
onDownloadProgress,
|
|
830
|
+
timeout,
|
|
831
|
+
onUploadProgress,
|
|
832
|
+
learningOmittedVersion,
|
|
833
|
+
noBusyRetry,
|
|
834
|
+
} = options;
|
|
835
|
+
if (method !== "GET" && omittedStrictDocument(client)) {
|
|
836
|
+
await learnOmittedVersion(client);
|
|
837
|
+
}
|
|
838
|
+
const keyed = takesIdempotencyKey(method, options);
|
|
839
|
+
const joins = joinsOperation(client, method, options);
|
|
840
|
+
// The ids the joined operation minted for what it creates (withOperation's
|
|
841
|
+
// `minted`): a create refused 409 id-taken for one of them was made by an
|
|
842
|
+
// earlier send of this operation, and answers as made.
|
|
843
|
+
const minted = joins ? client.operationGroup?.frames.at(-1)?.minted : null;
|
|
844
|
+
const { key, pin, record } = keyed ? nextIdempotencyKey(client, joins) : {};
|
|
845
|
+
const { url, requestBody, stampedGroup, stampedVersion } = prepareRequest(
|
|
846
|
+
client,
|
|
847
|
+
method,
|
|
848
|
+
path,
|
|
849
|
+
keyed ? { ...options, pinnedVersion: pin } : options,
|
|
850
|
+
);
|
|
851
|
+
if (keyed) record(stampedVersion);
|
|
196
852
|
|
|
197
853
|
// Build fetch options
|
|
198
854
|
const headers = {};
|
|
199
855
|
if (!noAuth) {
|
|
200
|
-
headers[
|
|
856
|
+
headers["Authorization"] = `Bearer ${client.token}`;
|
|
201
857
|
}
|
|
202
858
|
if (!formData) {
|
|
203
|
-
headers[
|
|
859
|
+
headers["Content-Type"] = "application/json";
|
|
204
860
|
}
|
|
861
|
+
if (keyed) headers[IDEMPOTENCY_HEADER] = key;
|
|
205
862
|
|
|
206
863
|
const fetchOptions = { method, headers };
|
|
207
864
|
if (requestBody !== undefined) {
|
|
208
865
|
fetchOptions.body = formData ? requestBody : JSON.stringify(requestBody);
|
|
209
866
|
}
|
|
210
|
-
|
|
211
|
-
|
|
867
|
+
|
|
868
|
+
// A fresh AbortSignal.timeout per attempt: the signal fires once and stays
|
|
869
|
+
// aborted, so reusing it would make every retry fail instantly.
|
|
870
|
+
const timeoutMs = timeout !== undefined ? timeout : client.timeout;
|
|
871
|
+
const send = () => {
|
|
872
|
+
if (onUploadProgress && typeof XMLHttpRequest !== "undefined") {
|
|
873
|
+
return xhrSend(url, fetchOptions, {
|
|
874
|
+
onUploadProgress,
|
|
875
|
+
timeout: timeoutMs,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
return fetchWithTimeout(url, fetchOptions, timeoutMs);
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// One attempt: a 503, and for a keyed write a 502 or 504, surfaces as a
|
|
882
|
+
// throw so the retries below can see it. Other failures are thrown from
|
|
883
|
+
// the checks after it.
|
|
884
|
+
// A write that lands on a held document renews its lock on the server, so
|
|
885
|
+
// its keeper hears of it (writesLanded).
|
|
886
|
+
const started =
|
|
887
|
+
method !== "GET" && !options.outOfBand ? writesStarted(client) : [];
|
|
888
|
+
const landed = (headers) =>
|
|
889
|
+
writesLanded(client, started, headers.get(REPLAYED_HEADER) === "true");
|
|
890
|
+
|
|
891
|
+
const attempt = async () => {
|
|
892
|
+
let res;
|
|
893
|
+
try {
|
|
894
|
+
res = await send();
|
|
895
|
+
} catch (e) {
|
|
896
|
+
throw makeNetworkError(e, url, method);
|
|
897
|
+
}
|
|
898
|
+
if (
|
|
899
|
+
res.status === 503 ||
|
|
900
|
+
(keyed && (res.status === 502 || res.status === 504))
|
|
901
|
+
) {
|
|
902
|
+
throw makeHttpError(res, await parseErrorBody(res), url, method);
|
|
903
|
+
}
|
|
904
|
+
return res;
|
|
905
|
+
};
|
|
212
906
|
|
|
213
907
|
try {
|
|
214
|
-
const response = await
|
|
908
|
+
const response = await withRetries(client, attempt, { keyed, noBusyRetry });
|
|
909
|
+
noteServerClock(client, response.headers);
|
|
215
910
|
|
|
216
911
|
if (!response.ok) {
|
|
217
|
-
const
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
912
|
+
const errorData = await parseErrorBody(response);
|
|
913
|
+
// Answered as made only for a single create, the one whose answer is
|
|
914
|
+
// the id alone. A bulk or a batch taken so is made whole (it is one
|
|
915
|
+
// transaction), and a caller that sends one decides what it answers.
|
|
916
|
+
if (
|
|
917
|
+
mintedTaken(response.status, errorData, minted) &&
|
|
918
|
+
requestBody?.id === errorData.id
|
|
919
|
+
) {
|
|
920
|
+
if (stampedGroup) stampedGroup.written = true;
|
|
921
|
+
return transformResponse({ id: errorData.id });
|
|
224
922
|
}
|
|
923
|
+
const error = makeHttpError(response, errorData, url, method);
|
|
924
|
+
noteAuthError(client, error);
|
|
225
925
|
throw error;
|
|
226
926
|
}
|
|
927
|
+
// The server took it, so the operation's group exists.
|
|
928
|
+
if (stampedGroup) stampedGroup.written = true;
|
|
227
929
|
|
|
228
|
-
// Binary response (
|
|
930
|
+
// Binary response (users.getAvatar)
|
|
229
931
|
if (binaryResponse) {
|
|
230
932
|
extractDocumentVersions(client, response.headers);
|
|
933
|
+
landed(response.headers);
|
|
934
|
+
if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
|
|
231
935
|
return await response.arrayBuffer();
|
|
232
936
|
}
|
|
937
|
+
if (blobResponse) {
|
|
938
|
+
extractDocumentVersions(client, response.headers);
|
|
939
|
+
landed(response.headers);
|
|
940
|
+
if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
|
|
941
|
+
return await readBlob(response, onDownloadProgress);
|
|
942
|
+
}
|
|
233
943
|
|
|
234
944
|
// JSON or text response
|
|
235
|
-
const contentType = response.headers.get(
|
|
236
|
-
if (contentType && contentType.includes(
|
|
945
|
+
const contentType = response.headers.get("content-type");
|
|
946
|
+
if (contentType && contentType.includes("application/json")) {
|
|
237
947
|
const data = await response.json();
|
|
238
|
-
extractDocumentVersions(client, response.headers, data
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
948
|
+
extractDocumentVersions(client, response.headers, data, {
|
|
949
|
+
historical: /[?&]as-of=/.test(url),
|
|
950
|
+
});
|
|
951
|
+
landed(response.headers);
|
|
952
|
+
if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
|
|
953
|
+
const answer = skipResponseTransform ? data : transformResponse(data);
|
|
954
|
+
return response.headers.get(REPLAYED_HEADER) === "true"
|
|
955
|
+
? markReplayed(answer)
|
|
956
|
+
: answer;
|
|
243
957
|
} else {
|
|
244
958
|
extractDocumentVersions(client, response.headers);
|
|
959
|
+
landed(response.headers);
|
|
960
|
+
if (!learningOmittedVersion) await learnOmittedVersionQuietly(client);
|
|
245
961
|
return await response.text();
|
|
246
962
|
}
|
|
247
963
|
} catch (error) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
throw
|
|
964
|
+
const escaped =
|
|
965
|
+
error.status !== undefined ? error : makeNetworkError(error, url, method);
|
|
966
|
+
if (keyed) escaped.idempotencyKey = key;
|
|
967
|
+
throw escaped;
|
|
252
968
|
}
|
|
253
969
|
}
|