@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 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
- /** The `code` is returned ONCE and is not recoverable afterward. */
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 asking Plaid to take a text edit plainly on the layer (see the manual's "Changing a text's body"). */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.20",
3
+ "version": "0.2.0-alpha.21",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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?,
@@ -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 stamp the lock endpoints return.
66
- * Comparing it against this machine's clock is the only way to learn a window
67
- * an operator has retuned, and it is also the one place a clock skew can get
68
- * in, so an answer outside a believable band falls back to the documented
69
- * default rather than to a beat that never fires or fires constantly.
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
- * @param {number|undefined} expiresAt
72
- * @param {number} nowMs
73
- * @param {number} [fallback]
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, nowMs, fallback = DOCUMENT_LOCK_TTL_MS) {
77
- if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
78
- const ttl = expiresAt - nowMs;
79
- if (ttl > 0 && ttl <= MAX_BELIEVABLE_TTL_MS) return ttl;
80
- }
81
- return fallback;
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
- // A window under two seconds leaves no room for a retry; treat it as a
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
- * Tries a `locked()` block makes at an acquire whose outcome is unknown, and the
223
- * pause before the next, which grows by this much each time.
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, which the server
241
- * answers 200 while that holder has it. When every attempt is unknown, the lock
242
- * it may hold is released on the way out, so it does not stand in everyone's
243
- * way until it expires.
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
- for (let attempt = 0; ; attempt += 1) {
247
- try {
248
- return await client.documents.acquireLock(documentId, undefined, lockId);
249
- } catch (error) {
250
- if (error?.status === 423) {
251
- // The error body is raw JSON off the wire, so it is still kebab-cased.
252
- const body = error.responseData || {};
253
- const holder = body.userId || body["user-id"] || "another user";
254
- const readable = new Error(
255
- `This document is being edited by ${holder}. Try again once they're done.`,
256
- );
257
- readable.status = 423;
258
- readable.statusText = error.statusText;
259
- readable.url = error.url;
260
- readable.method = error.method;
261
- readable.responseData = error.responseData;
262
- readable.cause = error;
263
- throw readable;
264
- }
265
- if (!UNKNOWN_OUTCOME_STATUSES.has(error?.status)) throw error;
266
- if (attempt + 1 < LOCK_ACQUIRE_ATTEMPTS) {
267
- await new Promise((resolve) =>
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?.expiresAt, Date.now());
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 four calls the batch
50
- // transport cannot carry at all (the multipart media and avatar uploads, the
51
- // user-data store's put and delete) and raises so the caller finds out. A
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`), an upload (`noBatch`) or a
244
- // call that mints a secret (`noIdempotency`) goes out with an
245
- // `Idempotency-Key` header, and so does every batch request (a queued op has
246
- // none: its batch request carries one). The server keeps the answer to a
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 the
709
- * batch transport cannot carry at all (see the note at the
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
- // Only a single create: a bulk refused whole made none of its rows.
820
- // Never a deleted row: there is nothing to open.
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 === 409 &&
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
- noIdempotency: true,
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
- noIdempotency: true,
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", { outOfBand: true }),
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 response = await retryUnknown(() => retryWhileBusy(attempt), {
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"), and the project it grants access to, if any.
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 { CONSTRAINT_TYPES, valueSetAllows, violationsOf } from "./constraints.js";
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,
@@ -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 where the server would refuse (a path through a non-object, or a
84
- * first key that is blank, over 200 characters or holds a control character).
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) asking Plaid to take a text edit plainly on the
39
- * layer: an edit inside a token, or touching its edge with no whitespace
40
- * between, grows or shrinks it, a typed space never splits it, a deleted one
41
- * never joins two, and only a token whose whole text is deleted goes. See the
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
 
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * {
18
18
  * schemaVersion: 1,
19
- * tasks: ["tokenize"], // controlled vocab; REPLACES tok:/asr: id prefixes
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, metalanguage and
67
- * orthography, and writes morphemes and glosses. Both claimed `analyze` for
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
- * Legacy id-prefix → task map, for services that have not yet migrated to a
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
- if (Array.isArray(declared) && declared.length) {
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 the end of the request.
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
- return streamServiceRequest(
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, stillRunning(new Error(`${what} could not be sent: ${error.message}`)));
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
- finish(reject, err);
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, stillRunning(new Error('Service closed the connection without a result')));
805
+ finish(reject, lostAnswer(new Error('Service closed the connection without a result')));
737
806
  } catch (error) {
738
- if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} stream error: ${error.message}`)));
807
+ if (error.name !== 'AbortError') finish(reject, lostAnswer(new Error(`${what} stream error: ${error.message}`)));
739
808
  }
740
809
  })();
741
810
  });