@larc-iu/plaid-client 0.2.0-alpha.20 → 0.2.0-alpha.21
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 +13 -23
- package/package.json +1 -1
- package/src/constraints.js +0 -11
- package/src/documentLock.js +54 -71
- package/src/http.js +36 -19
- package/src/index.js +67 -21
- package/src/metadataOps.js +4 -2
- package/src/provenance.js +0 -3
- package/src/roles.js +4 -13
- package/src/serviceSchema.js +5 -23
- package/src/services.js +75 -6
package/index.d.ts
CHANGED
|
@@ -667,7 +667,7 @@ interface Invite {
|
|
|
667
667
|
id: string;
|
|
668
668
|
/** "signup" | "password-reset" */
|
|
669
669
|
kind: string;
|
|
670
|
-
/** "active" | "used" | "expired" | "revoked" */
|
|
670
|
+
/** "active" | "used" | "expired" | "revoked" | "inactive" */
|
|
671
671
|
status: string;
|
|
672
672
|
createdBy: string;
|
|
673
673
|
createdAt: string;
|
|
@@ -685,7 +685,7 @@ interface Invite {
|
|
|
685
685
|
interface InvitePreview {
|
|
686
686
|
/** "signup" | "password-reset" */
|
|
687
687
|
kind: string;
|
|
688
|
-
/** "active" | "used" | "expired" | "revoked" */
|
|
688
|
+
/** "active" | "used" | "expired" | "revoked" | "inactive" */
|
|
689
689
|
status: string;
|
|
690
690
|
expiresAt: string;
|
|
691
691
|
grantAdmin: boolean;
|
|
@@ -977,7 +977,10 @@ interface InvitesBundle {
|
|
|
977
977
|
all?: boolean;
|
|
978
978
|
pageSize?: number;
|
|
979
979
|
}): AsyncGenerator<Invite[]>;
|
|
980
|
-
/**
|
|
980
|
+
/**
|
|
981
|
+
* The `code` is returned ONCE and is not recoverable afterward. Needs a
|
|
982
|
+
* signed-in session: a named API token cannot mint any invite (403).
|
|
983
|
+
*/
|
|
981
984
|
create(
|
|
982
985
|
opts?: CreateInviteOptions,
|
|
983
986
|
auditMessage?: string,
|
|
@@ -1069,9 +1072,6 @@ export declare class DocumentLockLost extends Error {
|
|
|
1069
1072
|
readonly cause?: unknown;
|
|
1070
1073
|
}
|
|
1071
1074
|
|
|
1072
|
-
/** The server's default document-lock window, in ms. */
|
|
1073
|
-
export const DOCUMENT_LOCK_TTL_MS: 60000;
|
|
1074
|
-
|
|
1075
1075
|
interface DocumentsBundle {
|
|
1076
1076
|
checkLock(documentId: string): Promise<any>;
|
|
1077
1077
|
/**
|
|
@@ -1615,6 +1615,10 @@ export declare class PlaidClient {
|
|
|
1615
1615
|
serverClockOffsetMs: number | null;
|
|
1616
1616
|
/** The server's time now, from its last response's Date header (to the second), else this machine's. Judge a time the server stamped, such as an audit entry's `ts`, against this rather than `Date.now()`. */
|
|
1617
1617
|
serverNow(): Date;
|
|
1618
|
+
/** Hear every time a request is sent again: after a 503, or for a keyed write after its answer was lost (no response, 502, 504). Called before the wait. Returns the unsubscribe. */
|
|
1619
|
+
onRetry(
|
|
1620
|
+
listener: (info: { attempt: number; retries: number; delay: number; error: Error }) => void,
|
|
1621
|
+
): () => void;
|
|
1618
1622
|
|
|
1619
1623
|
// Batches.
|
|
1620
1624
|
//
|
|
@@ -1702,8 +1706,6 @@ export function cpSlice(s: string, begin: number, end?: number): string;
|
|
|
1702
1706
|
export function cpSlicer(s: string): (begin: number, end?: number) => string;
|
|
1703
1707
|
/** UTF-16 index -> code-point index in `s`. */
|
|
1704
1708
|
export function utf16ToCp(s: string, u: number): number;
|
|
1705
|
-
/** Code-point index -> UTF-16 index in `s` (clamps past the end). */
|
|
1706
|
-
export function cpToUtf16(s: string, cp: number): number;
|
|
1707
1709
|
/** Like indexOf, but the result and `fromCp` are code-point indices; -1 if absent. */
|
|
1708
1710
|
export function cpIndexOf(s: string, sub: string, fromCp?: number): number;
|
|
1709
1711
|
|
|
@@ -1735,13 +1737,13 @@ export const PLAID_NAMESPACE: "plaid";
|
|
|
1735
1737
|
export const ROLE_KEY: "role";
|
|
1736
1738
|
/** Layer config key naming the metadata keys a token born of a SPLIT inherits. */
|
|
1737
1739
|
export const PRESERVE_ON_SPLIT_KEY: "preserveOnSplit";
|
|
1738
|
-
/** Layer config key
|
|
1739
|
-
export const PLAIN_EDITS_KEY: "plainEdits";
|
|
1740
|
-
/** Layer config key: a space typed inside a word of the text splits it (beside plainEdits). */
|
|
1740
|
+
/** Layer config key: a space typed inside a word of the text splits it (see the manual's "Changing a text's body"). */
|
|
1741
1741
|
export const SPLIT_ON_SPACE_KEY: "splitOnSpace";
|
|
1742
1742
|
|
|
1743
1743
|
/** The server's cap on operations per batch request; a larger batch goes as consecutive requests. */
|
|
1744
1744
|
export const MAX_BATCH_OPS: 1000;
|
|
1745
|
+
/** How long `admin.backup()` waits for the server to finish writing a backup, in ms. */
|
|
1746
|
+
export const BACKUP_TIMEOUT_MS: number;
|
|
1745
1747
|
/** The fixed role inventory; only these values are interoperable across apps. */
|
|
1746
1748
|
export const ROLES: {
|
|
1747
1749
|
readonly BASELINE: "baseline";
|
|
@@ -1854,20 +1856,12 @@ export function confirmedInferred(
|
|
|
1854
1856
|
};
|
|
1855
1857
|
/** Classify an entity's metadata into one of the three provenance states. */
|
|
1856
1858
|
export function provState(metadata: object | null | undefined): ProvState;
|
|
1857
|
-
/** The verifying fragment, { provConfirmed: true }: PATCH it over existing metadata. */
|
|
1858
|
-
export const PROV_CONFIRMED: { readonly provConfirmed: true };
|
|
1859
1859
|
/** Machine-made and not yet human-verified (needs review, replaceable, confirmable). */
|
|
1860
1860
|
export function isMachine(metadata: object | null | undefined): boolean;
|
|
1861
1861
|
/** Whether a machine writer must leave this entity alone (human or verified). !isMachine. */
|
|
1862
1862
|
export function isProtected(metadata: object | null | undefined): boolean;
|
|
1863
|
-
/** The fragment a HUMAN edit should merge in: PROV_CONFIRMED iff machine-unverified, else null. */
|
|
1864
|
-
export function verifyOnEdit(
|
|
1865
|
-
metadata: object | null | undefined,
|
|
1866
|
-
): { readonly provConfirmed: true } | null;
|
|
1867
1863
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
1868
1864
|
export function serviceSource(serviceId: string): string;
|
|
1869
|
-
/** Canonical provSource for a contributor: 'user:<userId>'. */
|
|
1870
|
-
export function userSource(userId: string): string;
|
|
1871
1865
|
/** The metadata fragment a contributor's work carries. */
|
|
1872
1866
|
export function stampContributed(userId: string): {
|
|
1873
1867
|
prov: "contributed";
|
|
@@ -1925,8 +1919,6 @@ export function isReservedMetadataKey(key: string): boolean;
|
|
|
1925
1919
|
// --- Review: whose work is reviewed (a project-config norm) -------------------
|
|
1926
1920
|
/** The config key, under the `plaid` namespace, holding the review lists. */
|
|
1927
1921
|
export const REVIEW_KEY: "review";
|
|
1928
|
-
/** The project roles a review list may name. */
|
|
1929
|
-
export const PROJECT_ROLES: readonly ["reader", "writer", "maintainer"];
|
|
1930
1922
|
/** A project's review lists, normalized. */
|
|
1931
1923
|
export function readReview(config?: object | null): {
|
|
1932
1924
|
users: string[];
|
|
@@ -2004,8 +1996,6 @@ export interface ConstraintViolation {
|
|
|
2004
1996
|
value?: any;
|
|
2005
1997
|
parts?: string[];
|
|
2006
1998
|
}
|
|
2007
|
-
/** The constraint types. */
|
|
2008
|
-
export const CONSTRAINT_TYPES: readonly string[];
|
|
2009
1999
|
/** The camelCased violations of a write refused by a layer constraint, or null. */
|
|
2010
2000
|
export function violationsOf(err: any): ConstraintViolation[] | null;
|
|
2011
2001
|
export function valueSetAllows(
|
package/package.json
CHANGED
package/src/constraints.js
CHANGED
|
@@ -7,17 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
import { transformResponse } from "./transforms.js";
|
|
9
9
|
|
|
10
|
-
/** The constraint types, by the layer kind that may carry them. */
|
|
11
|
-
export const CONSTRAINT_TYPES = Object.freeze([
|
|
12
|
-
"max-in-degree",
|
|
13
|
-
"acyclic",
|
|
14
|
-
"same-ancestor",
|
|
15
|
-
"single-span",
|
|
16
|
-
"value-set",
|
|
17
|
-
"coextensive",
|
|
18
|
-
"single-link",
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
10
|
/**
|
|
22
11
|
* The violations of a write refused by a layer constraint, camelCased
|
|
23
12
|
* ({constraint, namespace, layer, layerName, document, at, ids, value?,
|
package/src/documentLock.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isUnknownOutcome, retryUnknown } from "./http.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Keeping a document lock alive for as long as a `locked()` block runs.
|
|
3
5
|
*
|
|
@@ -27,21 +29,6 @@
|
|
|
27
29
|
* that is missing somewhere.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
|
-
/**
|
|
31
|
-
* How long plaid-core holds a document lock before it expires, in ms.
|
|
32
|
-
* `plaid.server.locks/default-lock-expiration-ms`. An operator can change it
|
|
33
|
-
* with `:plaid.server.locks/config :expiration-ms`, and a server publishes what
|
|
34
|
-
* it enforces as `lockExpirationMs` in `GET /info`. This is the last resort:
|
|
35
|
-
* what a live lock is renewed against is the `expiresAt` on the acquire
|
|
36
|
-
* response, which names the moment rather than the window.
|
|
37
|
-
*/
|
|
38
|
-
export const DOCUMENT_LOCK_TTL_MS = 60000;
|
|
39
|
-
|
|
40
|
-
// Widest lock lifetime we will believe from a server response. Past this the
|
|
41
|
-
// number is a clock skew between this machine and the server rather than a
|
|
42
|
-
// configured window, and the documented default is the better guess.
|
|
43
|
-
const MAX_BELIEVABLE_TTL_MS = 3600000;
|
|
44
|
-
|
|
45
32
|
/**
|
|
46
33
|
* The lock a `documents.locked()` block was holding is no longer held.
|
|
47
34
|
*
|
|
@@ -62,25 +49,31 @@ export class DocumentLockLost extends Error {
|
|
|
62
49
|
/**
|
|
63
50
|
* How long a freshly taken lock lasts, from the server's own answer.
|
|
64
51
|
*
|
|
65
|
-
* `expiresAt` is the epoch-millisecond
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
52
|
+
* `expiresAt` is the epoch-millisecond moment the lock endpoints return, on
|
|
53
|
+
* the server's clock, so it is read against the server's clock too
|
|
54
|
+
* (`client.serverNow()`): this machine's clock being off cannot stretch or
|
|
55
|
+
* shrink the window. It is how a window an operator has retuned
|
|
56
|
+
* (`:plaid.server.locks/config :expiration-ms`) reaches the keeper.
|
|
70
57
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
58
|
+
* An answer that cannot be a window (none, already over, past an hour) is
|
|
59
|
+
* core's default, 60 s.
|
|
60
|
+
*
|
|
61
|
+
* @param {number} expiresAt
|
|
62
|
+
* @param {number} serverNowMs
|
|
74
63
|
* @returns {number}
|
|
75
64
|
*/
|
|
76
|
-
export function lockTtlMs(expiresAt,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
65
|
+
export function lockTtlMs(expiresAt, serverNowMs) {
|
|
66
|
+
const ttl = expiresAt - serverNowMs;
|
|
67
|
+
// No answer at all, or one past an hour or already over: this machine's
|
|
68
|
+
// clock stood in for the server's (a page that cannot read its Date
|
|
69
|
+
// header) and is far off. Core's default window is the better guess.
|
|
70
|
+
return Number.isFinite(ttl) && ttl > 0 && ttl <= MAX_TTL_MS ? ttl : DEFAULT_TTL_MS;
|
|
82
71
|
}
|
|
83
72
|
|
|
73
|
+
// Core's default lock window, and the longest one believed.
|
|
74
|
+
const DEFAULT_TTL_MS = 60000;
|
|
75
|
+
const MAX_TTL_MS = 3600000;
|
|
76
|
+
|
|
84
77
|
/**
|
|
85
78
|
* Renews one document's lock until the block holding it exits.
|
|
86
79
|
*
|
|
@@ -100,9 +93,7 @@ export class LockKeeper {
|
|
|
100
93
|
) {
|
|
101
94
|
this._refresh = refresh;
|
|
102
95
|
this._documentId = documentId;
|
|
103
|
-
|
|
104
|
-
// misconfiguration and beat at the documented rate instead.
|
|
105
|
-
this._ttlMs = ttlMs >= 2000 ? ttlMs : DOCUMENT_LOCK_TTL_MS;
|
|
96
|
+
this._ttlMs = ttlMs;
|
|
106
97
|
this._onLost = onLost;
|
|
107
98
|
this._clock = clock;
|
|
108
99
|
this._sleep = sleep || ((ms) => this._defaultSleep(ms));
|
|
@@ -219,16 +210,11 @@ export class DocumentLock {
|
|
|
219
210
|
}
|
|
220
211
|
|
|
221
212
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
213
|
+
* The pause before sending again an acquire whose outcome is unknown, which
|
|
214
|
+
* grows by this much each time (three sends in all).
|
|
224
215
|
*/
|
|
225
|
-
export const LOCK_ACQUIRE_ATTEMPTS = 3;
|
|
226
216
|
export const LOCK_ACQUIRE_RETRY_MS = 500;
|
|
227
217
|
|
|
228
|
-
// The HTTP status of a request that may or may not have reached the server: no
|
|
229
|
-
// answer at all (0), or a proxy's 502 or 504.
|
|
230
|
-
const UNKNOWN_OUTCOME_STATUSES = new Set([0, 502, 504]);
|
|
231
|
-
|
|
232
218
|
function mintLockId() {
|
|
233
219
|
return globalThis.crypto.randomUUID();
|
|
234
220
|
}
|
|
@@ -237,45 +223,42 @@ function mintLockId() {
|
|
|
237
223
|
* The acquire of a `locked()` block, under the holder id it minted.
|
|
238
224
|
*
|
|
239
225
|
* An acquire whose outcome is unknown (no answer, a timeout, a 502 or a 504)
|
|
240
|
-
* may have taken the lock. It is sent again under the same id
|
|
241
|
-
* answers 200 while that holder has it.
|
|
242
|
-
*
|
|
243
|
-
* way until it
|
|
226
|
+
* may have taken the lock. It is sent again under the same id as any write is
|
|
227
|
+
* (`retryUnknown`), which the server answers 200 while that holder has it.
|
|
228
|
+
* When every send is unknown (or the network went), the lock it may hold is
|
|
229
|
+
* released on the way out, so it does not stand in everyone's way until it
|
|
230
|
+
* expires.
|
|
244
231
|
*/
|
|
245
232
|
async function takeLock(client, documentId, lockId, retryMs) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
setTimeout(resolve, retryMs * (attempt + 1)),
|
|
269
|
-
);
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
233
|
+
try {
|
|
234
|
+
return await retryUnknown(
|
|
235
|
+
() => client.documents.acquireLock(documentId, undefined, lockId),
|
|
236
|
+
{ delaysMs: [retryMs, 2 * retryMs] },
|
|
237
|
+
);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error?.status === 423) {
|
|
240
|
+
// The error body is raw JSON off the wire, so it is still kebab-cased.
|
|
241
|
+
const body = error.responseData || {};
|
|
242
|
+
const holder = body.userId || body["user-id"] || "another user";
|
|
243
|
+
const readable = new Error(
|
|
244
|
+
`This document is being edited by ${holder}. Try again once they're done.`,
|
|
245
|
+
);
|
|
246
|
+
readable.status = 423;
|
|
247
|
+
readable.statusText = error.statusText;
|
|
248
|
+
readable.url = error.url;
|
|
249
|
+
readable.method = error.method;
|
|
250
|
+
readable.responseData = error.responseData;
|
|
251
|
+
readable.cause = error;
|
|
252
|
+
throw readable;
|
|
253
|
+
}
|
|
254
|
+
if (isUnknownOutcome(error)) {
|
|
272
255
|
try {
|
|
273
256
|
await client.documents.releaseLock(documentId, lockId);
|
|
274
257
|
} catch {
|
|
275
258
|
/* the lock expires on its own */
|
|
276
259
|
}
|
|
277
|
-
throw error;
|
|
278
260
|
}
|
|
261
|
+
throw error;
|
|
279
262
|
}
|
|
280
263
|
}
|
|
281
264
|
|
|
@@ -300,7 +283,7 @@ export async function withDocumentLock(
|
|
|
300
283
|
const lockId = info?.lockId ?? minted;
|
|
301
284
|
let keeper = null;
|
|
302
285
|
if (keepAlive) {
|
|
303
|
-
const ttlMs = lockTtlMs(info
|
|
286
|
+
const ttlMs = lockTtlMs(info.expiresAt, client.serverNow().getTime());
|
|
304
287
|
client.documentLockLost = null;
|
|
305
288
|
keeper = new LockKeeper(
|
|
306
289
|
(id) => client.documents.renewLock(id, lockId),
|
package/src/http.js
CHANGED
|
@@ -46,9 +46,11 @@ import { uuidv7 } from "./ids.js";
|
|
|
46
46
|
// does not 409 the next. A batch split past MAX_BATCH_OPS restamps each later
|
|
47
47
|
// request with the version the one before it left (see submitBatch).
|
|
48
48
|
//
|
|
49
|
-
// `noBatch` is not part of that judgment. It marks the
|
|
50
|
-
//
|
|
51
|
-
//
|
|
49
|
+
// `noBatch` is not part of that judgment. It marks the calls a batch cannot
|
|
50
|
+
// carry at all (the multipart media and avatar uploads, the user-data store's
|
|
51
|
+
// put and delete, and minting an API token or an invite, whose answer is a
|
|
52
|
+
// secret the server refuses to keep with a keyed batch's answer) and raises
|
|
53
|
+
// so the caller finds out. A
|
|
52
54
|
// batch inside a batch is refused by the batch itself. Never put it on a read: it turns a swallowed read into a
|
|
53
55
|
// thrown one, which is what the chrome hit when an unrelated import was
|
|
54
56
|
// running. A blobless DELETE beside an upload is not one of them: it carries
|
|
@@ -240,10 +242,10 @@ export async function retryWhileBusy(
|
|
|
240
242
|
// ---------------------------------------------------------------------------
|
|
241
243
|
// The Idempotency-Key.
|
|
242
244
|
//
|
|
243
|
-
// Every write that is not a signal (`outOfBand`)
|
|
244
|
-
// call that mints a secret
|
|
245
|
-
// `Idempotency-Key` header, and so does every batch request (a queued
|
|
246
|
-
// none: its batch request carries one). The server keeps the answer to a
|
|
245
|
+
// Every write that is not a signal (`outOfBand`) or a call a batch cannot
|
|
246
|
+
// carry (`noBatch`: an upload, user data, a call that mints a secret) goes out
|
|
247
|
+
// with an `Idempotency-Key` header, and so does every batch request (a queued
|
|
248
|
+
// op has none: its batch request carries one). The server keeps the answer to a
|
|
247
249
|
// keyed write for a day, and the same key sent again for the same request is
|
|
248
250
|
// answered from it with `Idempotent-Replayed: true`, writing nothing. So a
|
|
249
251
|
// write whose answer was lost (no response, 502, 504) can be sent again
|
|
@@ -274,6 +276,22 @@ export function isUnknownOutcome(error) {
|
|
|
274
276
|
return status === 0 || status === 502 || status === 504;
|
|
275
277
|
}
|
|
276
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Whether a create refused with `status` and body `data` was made by an
|
|
281
|
+
* earlier send of the same work: `409 id-taken` for an id in `minted` (a Set
|
|
282
|
+
* of the ids the work minted for its creates), and not for a row deleted
|
|
283
|
+
* since (there is nothing to go on with). The one rule for a lost answer's
|
|
284
|
+
* create. The Python twin is `minted_taken`.
|
|
285
|
+
*/
|
|
286
|
+
export function mintedTaken(status, data, minted) {
|
|
287
|
+
return (
|
|
288
|
+
status === 409 &&
|
|
289
|
+
data?.error === "id-taken" &&
|
|
290
|
+
!data.deleted &&
|
|
291
|
+
!!minted?.has(data.id)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
277
295
|
/**
|
|
278
296
|
* Run `attempt`, sending it again after each delay in `delaysMs` while it
|
|
279
297
|
* fails with an unknown outcome. Only for a keyed write, whose resend is
|
|
@@ -588,8 +606,7 @@ export function takesIdempotencyKey(method, options = {}) {
|
|
|
588
606
|
return (
|
|
589
607
|
method !== "GET" &&
|
|
590
608
|
!options.outOfBand &&
|
|
591
|
-
!options.noBatch
|
|
592
|
-
!options.noIdempotency
|
|
609
|
+
!options.noBatch
|
|
593
610
|
);
|
|
594
611
|
}
|
|
595
612
|
|
|
@@ -705,8 +722,8 @@ export async function queueRequest(batch, method, path, options = {}) {
|
|
|
705
722
|
* rawBody - Body value passed directly (no transform). Mutually exclusive with body.
|
|
706
723
|
* formData - If true, body is FormData; skip Content-Type header
|
|
707
724
|
* queryParams - Object of query param key/values to append
|
|
708
|
-
* noBatch - If true, throw when made on a batch. Only for calls
|
|
709
|
-
* batch
|
|
725
|
+
* noBatch - If true, throw when made on a batch. Only for calls a
|
|
726
|
+
* batch cannot carry at all (see the note at the
|
|
710
727
|
* top of this file); never for a read.
|
|
711
728
|
* outOfBand - If true, the call is a signal rather than a write of
|
|
712
729
|
* project data: made on a batch it still goes over the
|
|
@@ -807,22 +824,22 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
807
824
|
};
|
|
808
825
|
|
|
809
826
|
try {
|
|
827
|
+
const onRetry = (info) => client._noteRetry?.(info);
|
|
810
828
|
const response = keyed
|
|
811
|
-
? await retryUnknown(() => retryWhileBusy(attempt), {
|
|
829
|
+
? await retryUnknown(() => retryWhileBusy(attempt, { onRetry }), {
|
|
812
830
|
delaysMs: client.retryDelaysMs,
|
|
831
|
+
onRetry,
|
|
813
832
|
})
|
|
814
|
-
: await retryWhileBusy(attempt);
|
|
833
|
+
: await retryWhileBusy(attempt, { onRetry });
|
|
815
834
|
noteServerClock(client, response.headers);
|
|
816
835
|
|
|
817
836
|
if (!response.ok) {
|
|
818
837
|
const errorData = await parseErrorBody(response);
|
|
819
|
-
//
|
|
820
|
-
//
|
|
838
|
+
// Answered as made only for a single create, the one whose answer is
|
|
839
|
+
// the id alone. A bulk or a batch taken so is made whole (it is one
|
|
840
|
+
// transaction), and a caller that sends one decides what it answers.
|
|
821
841
|
if (
|
|
822
|
-
response.status
|
|
823
|
-
errorData?.error === "id-taken" &&
|
|
824
|
-
!errorData.deleted &&
|
|
825
|
-
minted?.has(errorData.id) &&
|
|
842
|
+
mintedTaken(response.status, errorData, minted) &&
|
|
826
843
|
requestBody?.id === errorData.id
|
|
827
844
|
) {
|
|
828
845
|
if (stampedGroup) stampedGroup.written = true;
|
package/src/index.js
CHANGED
|
@@ -133,6 +133,12 @@ async function anonymousGet(baseUrl, path, options = {}) {
|
|
|
133
133
|
*/
|
|
134
134
|
const MAX_BATCH_OPS = 1000;
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* How long `admin.backup()` waits for the server to finish writing a backup:
|
|
138
|
+
* minutes on a large database, past the usual per-request timeout.
|
|
139
|
+
*/
|
|
140
|
+
export const BACKUP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
141
|
+
|
|
136
142
|
/**
|
|
137
143
|
* A batch is the client with a different `_request` (see `queueRequest`) and
|
|
138
144
|
* its own copy of the bundles. Everything else resolves to the client through
|
|
@@ -337,10 +343,37 @@ class PlaidClient {
|
|
|
337
343
|
// the stored token and route back to login. See makeRequest in http.js.
|
|
338
344
|
this.onAuthError = options.onAuthError || null;
|
|
339
345
|
this._authErrorFired = false;
|
|
346
|
+
// Who hears that a request is being sent again (onRetry).
|
|
347
|
+
this._retryListeners = new Set();
|
|
340
348
|
|
|
341
349
|
this._installResources();
|
|
342
350
|
}
|
|
343
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Hear every time a request is sent again: after a 503 (the database was
|
|
354
|
+
* busy), or for a keyed write after its answer was lost (no response, 502,
|
|
355
|
+
* 504). The listener gets `{ attempt, retries, delay, error }` before the
|
|
356
|
+
* wait. A screen that writes through the client directly can say it is
|
|
357
|
+
* retrying while the server does not answer, though the browser is online.
|
|
358
|
+
* @param {(info: {attempt: number, retries: number, delay: number, error: Error}) => void} listener
|
|
359
|
+
* @returns {() => void} the unsubscribe
|
|
360
|
+
*/
|
|
361
|
+
onRetry(listener) {
|
|
362
|
+
this._retryListeners.add(listener);
|
|
363
|
+
return () => this._retryListeners.delete(listener);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Tell every `onRetry` listener. A listener that throws is passed over. */
|
|
367
|
+
_noteRetry(info) {
|
|
368
|
+
for (const listener of this._retryListeners) {
|
|
369
|
+
try {
|
|
370
|
+
listener(info);
|
|
371
|
+
} catch (_) {
|
|
372
|
+
/* a listener must not stop the retry */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
344
377
|
/**
|
|
345
378
|
* The server's time now, as its last response's Date header put it (to the
|
|
346
379
|
* second), else this machine's. Judge a time the server stamped, such as an
|
|
@@ -1171,7 +1204,8 @@ class PlaidClient {
|
|
|
1171
1204
|
query: { q },
|
|
1172
1205
|
}),
|
|
1173
1206
|
/**
|
|
1174
|
-
* Create a new user.
|
|
1207
|
+
* Create a new user. Admin only, and not with a named API token (403):
|
|
1208
|
+
* it needs a sign-in token.
|
|
1175
1209
|
* @param {string} email - The account's email address. It becomes the
|
|
1176
1210
|
* user's id and is what they log in with; it can never be changed.
|
|
1177
1211
|
* @param {string} password - The password
|
|
@@ -1266,7 +1300,8 @@ class PlaidClient {
|
|
|
1266
1300
|
/**
|
|
1267
1301
|
* Modify a user. Admins may change the display name, password, and admin
|
|
1268
1302
|
* status of any user. All other users may only modify their own display
|
|
1269
|
-
* name or password.
|
|
1303
|
+
* name or password. A password change cannot be made with a named API
|
|
1304
|
+
* token (403): it needs a sign-in token.
|
|
1270
1305
|
*
|
|
1271
1306
|
* A user's id is their email address and is fixed for the life of the
|
|
1272
1307
|
* account — it is what they log in with, so nothing can change it.
|
|
@@ -1489,6 +1524,8 @@ class PlaidClient {
|
|
|
1489
1524
|
* Mint a named API token for a user. The returned `token` is the signed
|
|
1490
1525
|
* credential and is shown ONLY here — store it immediately. API tokens
|
|
1491
1526
|
* do not expire and survive password changes / logout; revoke to kill.
|
|
1527
|
+
* A client signed in with a named API token cannot do this (403): it
|
|
1528
|
+
* needs a sign-in token.
|
|
1492
1529
|
* @param {string} userId - The user ID who will own the token
|
|
1493
1530
|
* @param {string} name - A human label, e.g. "Stanza parser"
|
|
1494
1531
|
* @returns {Promise<{id: string, name: string, token: string}>}
|
|
@@ -1497,11 +1534,14 @@ class PlaidClient {
|
|
|
1497
1534
|
this._request("POST", `/api/v1/users/${userId}/tokens`, {
|
|
1498
1535
|
auditMessage,
|
|
1499
1536
|
body: bodyOf({ name }),
|
|
1500
|
-
// The answer is the secret, which the server never keeps
|
|
1501
|
-
|
|
1537
|
+
// The answer is the secret, which the server never keeps, so it
|
|
1538
|
+
// takes no Idempotency-Key and a batch cannot carry it.
|
|
1539
|
+
noBatch: true,
|
|
1502
1540
|
}),
|
|
1503
1541
|
/**
|
|
1504
1542
|
* Revoke a named API token (soft-revoke; idempotent).
|
|
1543
|
+
* A client signed in with a named API token cannot do this (403): it
|
|
1544
|
+
* needs a sign-in token.
|
|
1505
1545
|
* @param {string} userId - The user ID who owns the token
|
|
1506
1546
|
* @param {string} tokenId - The token ID to revoke
|
|
1507
1547
|
*/
|
|
@@ -1564,6 +1604,9 @@ class PlaidClient {
|
|
|
1564
1604
|
* invite, no password resets — so for a non-admin, projectId/projectRole
|
|
1565
1605
|
* are required in practice (403 without them).
|
|
1566
1606
|
*
|
|
1607
|
+
* Needs a signed-in session: a client signed in with a named API token
|
|
1608
|
+
* cannot mint any invite (403).
|
|
1609
|
+
*
|
|
1567
1610
|
* EVERY option is optional; `create()` with no arguments mints a
|
|
1568
1611
|
* single-use signup link granting nothing but an account. Two pairing
|
|
1569
1612
|
* rules the server enforces with a 400: projectId and projectRole must be
|
|
@@ -1593,8 +1636,9 @@ class PlaidClient {
|
|
|
1593
1636
|
) =>
|
|
1594
1637
|
this._request("POST", "/api/v1/invites", {
|
|
1595
1638
|
auditMessage,
|
|
1596
|
-
// The answer is the code, which the server never keeps
|
|
1597
|
-
|
|
1639
|
+
// The answer is the code, which the server never keeps, so it
|
|
1640
|
+
// takes no Idempotency-Key and a batch cannot carry it.
|
|
1641
|
+
noBatch: true,
|
|
1598
1642
|
body: bodyOf({
|
|
1599
1643
|
"project-id": projectId,
|
|
1600
1644
|
"project-role": projectRole,
|
|
@@ -1629,13 +1673,22 @@ class PlaidClient {
|
|
|
1629
1673
|
* Resolves to the backup block with `ok` reporting whether the snapshot
|
|
1630
1674
|
* succeeded. Uses VACUUM INTO, which only reads, so it is safe while
|
|
1631
1675
|
* people are working. Admin only.
|
|
1676
|
+
*
|
|
1677
|
+
* The server answers once the backup is written, which takes minutes on
|
|
1678
|
+
* a large database, so this waits up to `BACKUP_TIMEOUT_MS` (30 minutes)
|
|
1679
|
+
* rather than the client's usual timeout. A proxy in front of the server
|
|
1680
|
+
* may still give up first (a 504): the backup goes on, and its file
|
|
1681
|
+
* shows in `server().backup.backups` once it is written.
|
|
1632
1682
|
* @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
|
|
1633
1683
|
*
|
|
1634
1684
|
* outOfBand, as every admin action on the server itself is: none of them
|
|
1635
1685
|
* writes project data (see the note at the top of http.js).
|
|
1636
1686
|
*/
|
|
1637
1687
|
backup: () =>
|
|
1638
|
-
this._request("POST", "/api/v1/admin/backup", {
|
|
1688
|
+
this._request("POST", "/api/v1/admin/backup", {
|
|
1689
|
+
outOfBand: true,
|
|
1690
|
+
timeout: BACKUP_TIMEOUT_MS,
|
|
1691
|
+
}),
|
|
1639
1692
|
/**
|
|
1640
1693
|
* Documents currently held by an editing lock, with who holds each and
|
|
1641
1694
|
* when it expires on its own. Admin only.
|
|
@@ -3854,8 +3907,10 @@ class PlaidClient {
|
|
|
3854
3907
|
}
|
|
3855
3908
|
return res;
|
|
3856
3909
|
};
|
|
3857
|
-
const
|
|
3910
|
+
const onRetry = (info) => this._noteRetry(info);
|
|
3911
|
+
const response = await retryUnknown(() => retryWhileBusy(attempt, { onRetry }), {
|
|
3858
3912
|
delaysMs: this.retryDelaysMs,
|
|
3913
|
+
onRetry,
|
|
3859
3914
|
}).catch((error) => {
|
|
3860
3915
|
error.idempotencyKey = key;
|
|
3861
3916
|
// 401 means the token is missing, expired or invalid: fire the app's
|
|
@@ -3982,7 +4037,8 @@ class PlaidClient {
|
|
|
3982
4037
|
* Describe an invite code, with NO authentication — this is what a signup
|
|
3983
4038
|
* page calls before the redeemer has an account. Returns the kind of link
|
|
3984
4039
|
* ("signup" or "password-reset"), its status ("active", "used", "expired",
|
|
3985
|
-
* "revoked"
|
|
4040
|
+
* "revoked", or "inactive" when its creator can no longer grant what it
|
|
4041
|
+
* grants), and the project it grants access to, if any.
|
|
3986
4042
|
*
|
|
3987
4043
|
* Throws a 404-shaped error if the code is unknown. A known-but-dead code
|
|
3988
4044
|
* resolves normally with a non-"active" status, so the page can say why.
|
|
@@ -4176,7 +4232,7 @@ function constraintMethods(client, kind) {
|
|
|
4176
4232
|
|
|
4177
4233
|
export default PlaidClient;
|
|
4178
4234
|
export { PlaidClient };
|
|
4179
|
-
export {
|
|
4235
|
+
export { valueSetAllows, violationsOf } from "./constraints.js";
|
|
4180
4236
|
|
|
4181
4237
|
// Unicode code-point helpers for text offsets (token begin/end are code-point
|
|
4182
4238
|
// indices). See ./codepoint.js.
|
|
@@ -4185,7 +4241,6 @@ export {
|
|
|
4185
4241
|
cpSlice,
|
|
4186
4242
|
cpSlicer,
|
|
4187
4243
|
utf16ToCp,
|
|
4188
|
-
cpToUtf16,
|
|
4189
4244
|
cpIndexOf,
|
|
4190
4245
|
} from "./codepoint.js";
|
|
4191
4246
|
export { composeTextEdits, gapsToOps, applyTextOps } from "./textEdits.js";
|
|
@@ -4193,7 +4248,6 @@ export {
|
|
|
4193
4248
|
PLAID_NAMESPACE,
|
|
4194
4249
|
ROLE_KEY,
|
|
4195
4250
|
PRESERVE_ON_SPLIT_KEY,
|
|
4196
|
-
PLAIN_EDITS_KEY,
|
|
4197
4251
|
SPLIT_ON_SPACE_KEY,
|
|
4198
4252
|
ROLES,
|
|
4199
4253
|
readRole,
|
|
@@ -4222,11 +4276,7 @@ export { REQUESTED_BY, makeRequester, requesterOf } from "./requester.js";
|
|
|
4222
4276
|
// Document locks: `client.documents.locked()` holds one for a block of work and
|
|
4223
4277
|
// renews it while the block runs. DocumentLockLost is what a write throws once
|
|
4224
4278
|
// a renewal has failed. See ./documentLock.js.
|
|
4225
|
-
export {
|
|
4226
|
-
DOCUMENT_LOCK_TTL_MS,
|
|
4227
|
-
DocumentLock,
|
|
4228
|
-
DocumentLockLost,
|
|
4229
|
-
} from "./documentLock.js";
|
|
4279
|
+
export { DocumentLock, DocumentLockLost } from "./documentLock.js";
|
|
4230
4280
|
// Provenance: the cross-app convention for who made an annotation (flat
|
|
4231
4281
|
// prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
|
|
4232
4282
|
// a machine; 'contributed' = a person whose work is reviewed), plus the
|
|
@@ -4234,7 +4284,6 @@ export {
|
|
|
4234
4284
|
export {
|
|
4235
4285
|
PROV,
|
|
4236
4286
|
PROV_STATES,
|
|
4237
|
-
PROV_CONFIRMED,
|
|
4238
4287
|
PROVENANCE_KEYS,
|
|
4239
4288
|
stampInferred,
|
|
4240
4289
|
confirmedInferred,
|
|
@@ -4244,12 +4293,9 @@ export {
|
|
|
4244
4293
|
isMachine,
|
|
4245
4294
|
isProtected,
|
|
4246
4295
|
needsReview,
|
|
4247
|
-
verifyOnEdit,
|
|
4248
4296
|
contributeOnEdit,
|
|
4249
4297
|
serviceSource,
|
|
4250
|
-
userSource,
|
|
4251
4298
|
REVIEW_KEY,
|
|
4252
|
-
PROJECT_ROLES,
|
|
4253
4299
|
readReview,
|
|
4254
4300
|
projectRole,
|
|
4255
4301
|
isReviewed,
|
package/src/metadataOps.js
CHANGED
|
@@ -80,8 +80,10 @@ const applyOp = (node, path, depth, o) => {
|
|
|
80
80
|
/**
|
|
81
81
|
* Apply ops to a local copy of an entity's metadata the way the server does,
|
|
82
82
|
* for an optimistic update. Returns a new object and never mutates its input.
|
|
83
|
-
* Throws
|
|
84
|
-
*
|
|
83
|
+
* Throws on part of what the server refuses: an empty path, an op other than
|
|
84
|
+
* set or delete, a path through a non-object, or a first key that is blank,
|
|
85
|
+
* over 200 characters or holds a control character. The server's caps on
|
|
86
|
+
* depth, key count, string length and size are not checked here.
|
|
85
87
|
* @param {Object|null|undefined} metadata
|
|
86
88
|
* @param {Array<{op: string, path: string[], value?: any}>} ops
|
|
87
89
|
* @returns {Object}
|
package/src/provenance.js
CHANGED
|
@@ -257,9 +257,6 @@ export const userSource = (userId) => `user:${userId}`;
|
|
|
257
257
|
/** The config key, under the `plaid` namespace, holding the review lists. */
|
|
258
258
|
export const REVIEW_KEY = 'review';
|
|
259
259
|
|
|
260
|
-
/** The project roles a review list may name. */
|
|
261
|
-
export const PROJECT_ROLES = Object.freeze(['reader', 'writer', 'maintainer']);
|
|
262
|
-
|
|
263
260
|
/**
|
|
264
261
|
* The project's review lists, normalized: { users: string[], roles: string[] }.
|
|
265
262
|
* @param {Object|null|undefined} config - a project's `config`
|
package/src/roles.js
CHANGED
|
@@ -35,19 +35,10 @@ export const ROLE_KEY = 'role';
|
|
|
35
35
|
export const PRESERVE_ON_SPLIT_KEY = 'preserveOnSplit';
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Layer config key (true)
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* manual's `Changing a text's body`.
|
|
43
|
-
*/
|
|
44
|
-
export const PLAIN_EDITS_KEY = 'plainEdits';
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Layer config key (true), beside PLAIN_EDITS_KEY: a space typed inside a
|
|
48
|
-
* token of the text's words splits it, the token going on the half sharing
|
|
49
|
-
* more letters with it. Declared on a word layer another app shares, it holds
|
|
50
|
-
* for the whole text.
|
|
38
|
+
* Layer config key (true): a space typed inside a token of the text's words
|
|
39
|
+
* splits it, the token going on the half sharing more letters with it.
|
|
40
|
+
* Declared on a word layer another app shares, it holds for the whole text.
|
|
41
|
+
* See the manual's `Changing a text's body`.
|
|
51
42
|
*/
|
|
52
43
|
export const SPLIT_ON_SPACE_KEY = 'splitOnSpace';
|
|
53
44
|
|
package/src/serviceSchema.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* {
|
|
18
18
|
* schemaVersion: 1,
|
|
19
|
-
* tasks: ["tokenize"], // controlled vocab
|
|
19
|
+
* tasks: ["tokenize"], // controlled vocab (TASKS)
|
|
20
20
|
* summary: "## markdown …", // rich human description
|
|
21
21
|
* parameters: [ // ordered; rendered into a form
|
|
22
22
|
* { key, label, type, description?, default?, required?,
|
|
@@ -63,8 +63,8 @@ export const TASKS = Object.freeze({
|
|
|
63
63
|
*
|
|
64
64
|
* Apart from `analyze` on purpose. A task is the CONTRACT a spot offers a
|
|
65
65
|
* service against, and these two share almost none of it: an analyze
|
|
66
|
-
* request names the three token layers plus the language
|
|
67
|
-
*
|
|
66
|
+
* request names the three token layers plus the language and metalanguage,
|
|
67
|
+
* and writes morphemes and glosses. Both claimed `analyze` for
|
|
68
68
|
* a while, so every app offering an analyze spot offered the other app's
|
|
69
69
|
* services, which could not answer the request they would have been given.
|
|
70
70
|
*/
|
|
@@ -93,32 +93,14 @@ export const TASKS = Object.freeze({
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
*
|
|
97
|
-
* declared `tasks` array. Drop once all services advertise `tasks`.
|
|
98
|
-
*/
|
|
99
|
-
const LEGACY_TASK_PREFIXES = Object.freeze({
|
|
100
|
-
[TASKS.TOKENIZE]: "tok:",
|
|
101
|
-
[TASKS.TRANSCRIBE]: "asr:",
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Does `service` serve `task`? Prefers the declared `extras.tasks` array; falls
|
|
106
|
-
* back to the legacy id-prefix convention for un-migrated services.
|
|
96
|
+
* Does `service` serve `task`, as its declared `extras.tasks` says?
|
|
107
97
|
* @param {{serviceId?: string, extras?: {tasks?: string[]}}} service
|
|
108
98
|
* @param {string} task one of TASKS
|
|
109
99
|
* @returns {boolean}
|
|
110
100
|
*/
|
|
111
101
|
export function servesTask(service, task) {
|
|
112
102
|
const declared = service?.extras?.tasks;
|
|
113
|
-
|
|
114
|
-
return declared.includes(task);
|
|
115
|
-
}
|
|
116
|
-
const prefix = LEGACY_TASK_PREFIXES[task];
|
|
117
|
-
return (
|
|
118
|
-
!!prefix &&
|
|
119
|
-
typeof service?.serviceId === "string" &&
|
|
120
|
-
service.serviceId.startsWith(prefix)
|
|
121
|
-
);
|
|
103
|
+
return Array.isArray(declared) && declared.includes(task);
|
|
122
104
|
}
|
|
123
105
|
|
|
124
106
|
/**
|
package/src/services.js
CHANGED
|
@@ -434,7 +434,10 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
434
434
|
* you already made rejoins it instead of starting another.
|
|
435
435
|
*
|
|
436
436
|
* Errors that leave the request alive carry `pending: true`: a timeout, a
|
|
437
|
-
* stop, a dropped connection. An error without it is
|
|
437
|
+
* stop, a dropped connection, a proxy's 502 or 504. An error without it is
|
|
438
|
+
* the end of the request. With `opts.requestId`, a request whose answer went
|
|
439
|
+
* missing (a dropped connection, a 502 or 504) is rejoined by that id a few
|
|
440
|
+
* times before the error is given.
|
|
438
441
|
*
|
|
439
442
|
* @param {Object} client - PlaidClient instance
|
|
440
443
|
* @param {string} projectId - Project UUID
|
|
@@ -485,7 +488,7 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
485
488
|
if (opts.requestId) query.set('request-id', opts.requestId);
|
|
486
489
|
if (opts.projectIds?.length) query.set('project-ids', opts.projectIds.join(','));
|
|
487
490
|
const qs = String(query) ? `?${query}` : '';
|
|
488
|
-
|
|
491
|
+
const submit = () => streamServiceRequest(
|
|
489
492
|
client,
|
|
490
493
|
{
|
|
491
494
|
url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
|
|
@@ -500,6 +503,14 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
500
503
|
signal,
|
|
501
504
|
opts.onAccepted,
|
|
502
505
|
);
|
|
506
|
+
// A request whose id is known is rejoined when its answer goes missing, so
|
|
507
|
+
// a run the service took and finished is not reported failed.
|
|
508
|
+
if (!opts.requestId) return submit();
|
|
509
|
+
return rejoinLost(
|
|
510
|
+
submit,
|
|
511
|
+
() => attachServiceRequest(client, projectId, opts.requestId, timeout, onProgress, signal),
|
|
512
|
+
signal,
|
|
513
|
+
);
|
|
503
514
|
}
|
|
504
515
|
|
|
505
516
|
/**
|
|
@@ -612,6 +623,62 @@ export function cancelServiceRequest(client, projectId, requestId) {
|
|
|
612
623
|
*/
|
|
613
624
|
const stillRunning = (err) => Object.assign(err, { pending: true });
|
|
614
625
|
|
|
626
|
+
/**
|
|
627
|
+
* A still-running error whose cause is that the ANSWER went missing: the
|
|
628
|
+
* request could not be sent or read to its end, or a proxy answered for the
|
|
629
|
+
* server (502, 504). Not a timeout or a stop, which are the caller's own
|
|
630
|
+
* choice to stop waiting. A submit whose answer was lost rejoins its request
|
|
631
|
+
* (`rejoinLost`).
|
|
632
|
+
*/
|
|
633
|
+
const lostAnswer = (err) => Object.assign(stillRunning(err), { lostAnswer: true });
|
|
634
|
+
|
|
635
|
+
/** How long to wait before each attempt to rejoin a request whose answer was lost. */
|
|
636
|
+
const REJOIN_DELAYS_MS = [1000, 3000, 9000];
|
|
637
|
+
|
|
638
|
+
const delay = (ms, signal) =>
|
|
639
|
+
new Promise((resolve) => {
|
|
640
|
+
if (signal?.aborted) return resolve();
|
|
641
|
+
const timer = setTimeout(resolve, ms);
|
|
642
|
+
signal?.addEventListener(
|
|
643
|
+
'abort',
|
|
644
|
+
() => {
|
|
645
|
+
clearTimeout(timer);
|
|
646
|
+
resolve();
|
|
647
|
+
},
|
|
648
|
+
{ once: true },
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* `submit()` again, by attaching to the request `requestId` names, while its
|
|
654
|
+
* answer is the one that went missing. The request was taken, or never
|
|
655
|
+
* reached the server: attaching follows a taken one to its end, and a 404
|
|
656
|
+
* says the server never had it, so the first error stands. Each attempt
|
|
657
|
+
* whose answer goes missing too waits a little longer before the next.
|
|
658
|
+
*/
|
|
659
|
+
async function rejoinLost(submit, attach, signal) {
|
|
660
|
+
let first;
|
|
661
|
+
try {
|
|
662
|
+
return await submit();
|
|
663
|
+
} catch (err) {
|
|
664
|
+
if (!err?.lostAnswer) throw err;
|
|
665
|
+
first = err;
|
|
666
|
+
}
|
|
667
|
+
let last = first;
|
|
668
|
+
for (const ms of REJOIN_DELAYS_MS) {
|
|
669
|
+
await delay(ms, signal);
|
|
670
|
+
if (signal?.aborted) throw last;
|
|
671
|
+
try {
|
|
672
|
+
return await attach();
|
|
673
|
+
} catch (err) {
|
|
674
|
+
if (err?.status === 404) throw first;
|
|
675
|
+
if (!err?.lostAnswer) throw err;
|
|
676
|
+
last = err;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
throw last;
|
|
680
|
+
}
|
|
681
|
+
|
|
615
682
|
/**
|
|
616
683
|
* Open a request stream (submit or attach) and read it to its terminal
|
|
617
684
|
* event: `accepted` names the request, `progress` events go to `onProgress`,
|
|
@@ -679,7 +746,7 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
679
746
|
} catch (error) {
|
|
680
747
|
// The POST may or may not have reached the server. Treat it as alive:
|
|
681
748
|
// rejoining a request that was never made simply 404s.
|
|
682
|
-
if (error.name !== 'AbortError') finish(reject,
|
|
749
|
+
if (error.name !== 'AbortError') finish(reject, lostAnswer(new Error(`${what} could not be sent: ${error.message}`)));
|
|
683
750
|
return;
|
|
684
751
|
}
|
|
685
752
|
|
|
@@ -691,7 +758,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
691
758
|
if (!response.ok) {
|
|
692
759
|
const err = new Error(`${what} failed: HTTP ${response.status} ${response.statusText}`);
|
|
693
760
|
err.status = response.status;
|
|
694
|
-
|
|
761
|
+
// A proxy's 502 or 504 is no answer from the server: the request may
|
|
762
|
+
// have been taken, and the service may be running it.
|
|
763
|
+
finish(reject, response.status === 502 || response.status === 504 ? lostAnswer(err) : err);
|
|
695
764
|
return;
|
|
696
765
|
}
|
|
697
766
|
|
|
@@ -733,9 +802,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
733
802
|
}
|
|
734
803
|
}
|
|
735
804
|
}
|
|
736
|
-
finish(reject,
|
|
805
|
+
finish(reject, lostAnswer(new Error('Service closed the connection without a result')));
|
|
737
806
|
} catch (error) {
|
|
738
|
-
if (error.name !== 'AbortError') finish(reject,
|
|
807
|
+
if (error.name !== 'AbortError') finish(reject, lostAnswer(new Error(`${what} stream error: ${error.message}`)));
|
|
739
808
|
}
|
|
740
809
|
})();
|
|
741
810
|
});
|