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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  REPLAYED_HEADER,
21
21
  DEFAULT_TIMEOUT_MS,
22
22
  DEFAULT_BATCH_TIMEOUT_MS,
23
+ queryTimeout,
23
24
  } from "./http.js";
24
25
  import { uuidv7 } from "./ids.js";
25
26
  import { constraintsBody } from "./constraints.js";
@@ -133,6 +134,27 @@ async function anonymousGet(baseUrl, path, options = {}) {
133
134
  */
134
135
  const MAX_BATCH_OPS = 1000;
135
136
 
137
+ /**
138
+ * The kinds an operation may say it is (`?group-kind=`), the server's closed
139
+ * list (plaid.sql.operation-group/kinds). An operation of any other kind is
140
+ * refused when it begins, before it writes anything.
141
+ */
142
+ const OPERATION_KINDS = [
143
+ "assistant-plan",
144
+ "service-run",
145
+ "import",
146
+ "bulk-edit",
147
+ "guess-adoption",
148
+ "repair",
149
+ "review",
150
+ ];
151
+
152
+ /**
153
+ * How long `admin.backup()` waits for the server to finish writing a backup:
154
+ * minutes on a large database, past the usual per-request timeout.
155
+ */
156
+ export const BACKUP_TIMEOUT_MS = 30 * 60 * 1000;
157
+
136
158
  /**
137
159
  * A batch is the client with a different `_request` (see `queueRequest`) and
138
160
  * its own copy of the bundles. Everything else resolves to the client through
@@ -337,10 +359,37 @@ class PlaidClient {
337
359
  // the stored token and route back to login. See makeRequest in http.js.
338
360
  this.onAuthError = options.onAuthError || null;
339
361
  this._authErrorFired = false;
362
+ // Who hears that a request is being sent again (onRetry).
363
+ this._retryListeners = new Set();
340
364
 
341
365
  this._installResources();
342
366
  }
343
367
 
368
+ /**
369
+ * Hear every time a request is sent again: after a 503 (the database was
370
+ * busy), or for a keyed write after its answer was lost (no response, 502,
371
+ * 504). The listener gets `{ attempt, retries, delay, error }` before the
372
+ * wait. A screen that writes through the client directly can say it is
373
+ * retrying while the server does not answer, though the browser is online.
374
+ * @param {(info: {attempt: number, retries: number, delay: number, error: Error}) => void} listener
375
+ * @returns {() => void} the unsubscribe
376
+ */
377
+ onRetry(listener) {
378
+ this._retryListeners.add(listener);
379
+ return () => this._retryListeners.delete(listener);
380
+ }
381
+
382
+ /** Tell every `onRetry` listener. A listener that throws is passed over. */
383
+ _noteRetry(info) {
384
+ for (const listener of this._retryListeners) {
385
+ try {
386
+ listener(info);
387
+ } catch (_) {
388
+ /* a listener must not stop the retry */
389
+ }
390
+ }
391
+ }
392
+
344
393
  /**
345
394
  * The server's time now, as its last response's Date header put it (to the
346
395
  * second), else this machine's. Judge a time the server stamped, such as an
@@ -495,13 +544,17 @@ class PlaidClient {
495
544
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
496
545
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
497
546
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
547
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
548
+ * operations in `ops` (1..1000). `opCount` on every entry says how
549
+ * many it has
550
+ * @param {string} [opts.entryId] - Read only this entry, not a page
498
551
  * @param {string} [opts.itemId] - Only the changes that wrote this one
499
552
  * entry, each with only its operations that did
500
553
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
501
554
  */
502
555
  auditPage: (
503
556
  id,
504
- { startTime, endTime, opTypes, kinds, order, limit, cursor, itemId } = {},
557
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId, itemId } = {},
505
558
  ) =>
506
559
  listPage(this, `/api/v1/vocab-layers/${id}/audit`, {
507
560
  limit,
@@ -512,6 +565,8 @@ class PlaidClient {
512
565
  "op-types": opTypesParam(opTypes),
513
566
  kinds: kindsParam(kinds),
514
567
  order,
568
+ "ops-limit": opsLimit,
569
+ "entry-id": entryId,
515
570
  "item-id": itemId,
516
571
  },
517
572
  }),
@@ -1140,14 +1195,14 @@ class PlaidClient {
1140
1195
  * List (or search) users. Transparently follows pagination cursors and
1141
1196
  * returns the full flat array. Admin-or-maintainer only.
1142
1197
  * @param {object} [opts]
1143
- * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
1198
+ * @param {string} [opts.q] - Filter to users whose display name contains this text, or whose email starts with it (contains it, when the text has an @), case-insensitive
1144
1199
  */
1145
1200
  list: ({ q } = {}) =>
1146
1201
  listAll(this, "/api/v1/users", { query: { q } }),
1147
1202
  /**
1148
1203
  * Fetch a single page of users (optionally filtered by `q`).
1149
1204
  * @param {object} [opts]
1150
- * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
1205
+ * @param {string} [opts.q] - Filter to users whose display name contains this text, or whose email starts with it (contains it, when the text has an @), case-insensitive
1151
1206
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1152
1207
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1153
1208
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
@@ -1161,7 +1216,7 @@ class PlaidClient {
1161
1216
  /**
1162
1217
  * Async-iterate users page by page; yields each page's entries array.
1163
1218
  * @param {object} [opts]
1164
- * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
1219
+ * @param {string} [opts.q] - Filter to users whose display name contains this text, or whose email starts with it (contains it, when the text has an @), case-insensitive
1165
1220
  * @param {number} [opts.pageSize] - Per-request page size
1166
1221
  * @returns {AsyncGenerator<Array>}
1167
1222
  */
@@ -1171,7 +1226,8 @@ class PlaidClient {
1171
1226
  query: { q },
1172
1227
  }),
1173
1228
  /**
1174
- * Create a new user.
1229
+ * Create a new user. Admin only, and not with a named API token (403):
1230
+ * it needs a sign-in token.
1175
1231
  * @param {string} email - The account's email address. It becomes the
1176
1232
  * user's id and is what they log in with; it can never be changed.
1177
1233
  * @param {string} password - The password
@@ -1222,11 +1278,15 @@ class PlaidClient {
1222
1278
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
1223
1279
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1224
1280
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1281
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
1282
+ * operations in `ops` (1..1000). `opCount` on every entry says how
1283
+ * many it has
1284
+ * @param {string} [opts.entryId] - Read only this entry, not a page
1225
1285
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1226
1286
  */
1227
1287
  auditPage: (
1228
1288
  userId,
1229
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
1289
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
1230
1290
  ) =>
1231
1291
  listPage(this, `/api/v1/users/${userId}/audit`, {
1232
1292
  limit,
@@ -1237,6 +1297,8 @@ class PlaidClient {
1237
1297
  "op-types": opTypesParam(opTypes),
1238
1298
  kinds: kindsParam(kinds),
1239
1299
  order,
1300
+ "ops-limit": opsLimit,
1301
+ "entry-id": entryId,
1240
1302
  },
1241
1303
  }),
1242
1304
  /**
@@ -1266,7 +1328,8 @@ class PlaidClient {
1266
1328
  /**
1267
1329
  * Modify a user. Admins may change the display name, password, and admin
1268
1330
  * status of any user. All other users may only modify their own display
1269
- * name or password.
1331
+ * name or password. A password change cannot be made with a named API
1332
+ * token (403): it needs a sign-in token.
1270
1333
  *
1271
1334
  * A user's id is their email address and is fixed for the life of the
1272
1335
  * account — it is what they log in with, so nothing can change it.
@@ -1489,6 +1552,8 @@ class PlaidClient {
1489
1552
  * Mint a named API token for a user. The returned `token` is the signed
1490
1553
  * credential and is shown ONLY here — store it immediately. API tokens
1491
1554
  * do not expire and survive password changes / logout; revoke to kill.
1555
+ * A client signed in with a named API token cannot do this (403): it
1556
+ * needs a sign-in token.
1492
1557
  * @param {string} userId - The user ID who will own the token
1493
1558
  * @param {string} name - A human label, e.g. "Stanza parser"
1494
1559
  * @returns {Promise<{id: string, name: string, token: string}>}
@@ -1497,11 +1562,14 @@ class PlaidClient {
1497
1562
  this._request("POST", `/api/v1/users/${userId}/tokens`, {
1498
1563
  auditMessage,
1499
1564
  body: bodyOf({ name }),
1500
- // The answer is the secret, which the server never keeps.
1501
- noIdempotency: true,
1565
+ // The answer is the secret, which the server never keeps, so it
1566
+ // takes no Idempotency-Key and a batch cannot carry it.
1567
+ noBatch: true,
1502
1568
  }),
1503
1569
  /**
1504
1570
  * Revoke a named API token (soft-revoke; idempotent).
1571
+ * A client signed in with a named API token cannot do this (403): it
1572
+ * needs a sign-in token.
1505
1573
  * @param {string} userId - The user ID who owns the token
1506
1574
  * @param {string} tokenId - The token ID to revoke
1507
1575
  */
@@ -1564,6 +1632,9 @@ class PlaidClient {
1564
1632
  * invite, no password resets — so for a non-admin, projectId/projectRole
1565
1633
  * are required in practice (403 without them).
1566
1634
  *
1635
+ * Needs a signed-in session: a client signed in with a named API token
1636
+ * cannot mint any invite (403).
1637
+ *
1567
1638
  * EVERY option is optional; `create()` with no arguments mints a
1568
1639
  * single-use signup link granting nothing but an account. Two pairing
1569
1640
  * rules the server enforces with a 400: projectId and projectRole must be
@@ -1593,8 +1664,9 @@ class PlaidClient {
1593
1664
  ) =>
1594
1665
  this._request("POST", "/api/v1/invites", {
1595
1666
  auditMessage,
1596
- // The answer is the code, which the server never keeps.
1597
- noIdempotency: true,
1667
+ // The answer is the code, which the server never keeps, so it
1668
+ // takes no Idempotency-Key and a batch cannot carry it.
1669
+ noBatch: true,
1598
1670
  body: bodyOf({
1599
1671
  "project-id": projectId,
1600
1672
  "project-role": projectRole,
@@ -1629,13 +1701,22 @@ class PlaidClient {
1629
1701
  * Resolves to the backup block with `ok` reporting whether the snapshot
1630
1702
  * succeeded. Uses VACUUM INTO, which only reads, so it is safe while
1631
1703
  * people are working. Admin only.
1704
+ *
1705
+ * The server answers once the backup is written, which takes minutes on
1706
+ * a large database, so this waits up to `BACKUP_TIMEOUT_MS` (30 minutes)
1707
+ * rather than the client's usual timeout. A proxy in front of the server
1708
+ * may still give up first (a 504): the backup goes on, and its file
1709
+ * shows in `server().backup.backups` once it is written.
1632
1710
  * @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
1633
1711
  *
1634
1712
  * outOfBand, as every admin action on the server itself is: none of them
1635
1713
  * writes project data (see the note at the top of http.js).
1636
1714
  */
1637
1715
  backup: () =>
1638
- this._request("POST", "/api/v1/admin/backup", { outOfBand: true }),
1716
+ this._request("POST", "/api/v1/admin/backup", {
1717
+ outOfBand: true,
1718
+ timeout: BACKUP_TIMEOUT_MS,
1719
+ }),
1639
1720
  /**
1640
1721
  * Documents currently held by an editing lock, with who holds each and
1641
1722
  * when it expires on its own. Admin only.
@@ -1786,9 +1867,13 @@ class PlaidClient {
1786
1867
  * is what a feed wants. A cursor belongs to the direction that made it.
1787
1868
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1788
1869
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1870
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
1871
+ * operations in `ops` (1..1000). `opCount` on every entry says how
1872
+ * many it has
1873
+ * @param {string} [opts.entryId] - Read only this entry, not a page
1789
1874
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1790
1875
  */
1791
- listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor } = {}) =>
1876
+ listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {}) =>
1792
1877
  listPage(this, "/api/v1/audit", {
1793
1878
  limit,
1794
1879
  cursor,
@@ -1798,6 +1883,8 @@ class PlaidClient {
1798
1883
  "op-types": opTypesParam(opTypes),
1799
1884
  kinds: kindsParam(kinds),
1800
1885
  order,
1886
+ "ops-limit": opsLimit,
1887
+ "entry-id": entryId,
1801
1888
  },
1802
1889
  }),
1803
1890
  /**
@@ -2059,6 +2146,11 @@ class PlaidClient {
2059
2146
  }),
2060
2147
  /**
2061
2148
  * Upload a media file for a document. Uses Apache Tika for content validation.
2149
+ * A document that already has a recording refuses it with a 409
2150
+ * (`media-exists`, and the current `media-url`): delete that one first.
2151
+ * A WAV whose samples are not PCM or float (IMA or MS ADPCM, GSM, A-law
2152
+ * and the rest), which browsers cannot play, is refused with a 415
2153
+ * whose `error` names its coding.
2062
2154
  * @param {string} documentId - The document ID
2063
2155
  * @param {File} file - The file to upload
2064
2156
  * @param {string} [auditMessage] - Custom audit-log message for this write
@@ -2078,15 +2170,21 @@ class PlaidClient {
2078
2170
  /**
2079
2171
  * Delete media file for a document
2080
2172
  * @param {string} documentId - The document ID
2173
+ * @param {string} [auditMessage] - Audit message for this write
2174
+ * @param {{mediaVersion?: string}} [options] - `mediaVersion`: the
2175
+ * recording meant, the `?v=` of the document's `mediaUrl`. The delete
2176
+ * is refused with a 409 (`media-changed`, and the current `media-url`)
2177
+ * when the stored recording is another one.
2081
2178
  */
2082
2179
  // The upload above is multipart and cannot be batched, but a DELETE
2083
2180
  // carries no blob, so the batch transport takes it. It is a write
2084
2181
  // of the document's own data and queues like any other. Note that the
2085
2182
  // file removal happens outside the server's transaction, so a batch that
2086
2183
  // aborts after this op does not bring the file back.
2087
- deleteMedia: (documentId, auditMessage) =>
2184
+ deleteMedia: (documentId, auditMessage, { mediaVersion } = {}) =>
2088
2185
  this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
2089
2186
  auditMessage,
2187
+ queryParams: { "media-version": mediaVersion },
2090
2188
  }),
2091
2189
  /**
2092
2190
  * Replace all metadata for a document.
@@ -2147,11 +2245,15 @@ class PlaidClient {
2147
2245
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2148
2246
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2149
2247
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2248
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
2249
+ * operations in `ops` (1..1000). `opCount` on every entry says how
2250
+ * many it has
2251
+ * @param {string} [opts.entryId] - Read only this entry, not a page
2150
2252
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2151
2253
  */
2152
2254
  auditPage: (
2153
2255
  documentId,
2154
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2256
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
2155
2257
  ) =>
2156
2258
  listPage(this, `/api/v1/documents/${documentId}/audit`, {
2157
2259
  limit,
@@ -2162,6 +2264,8 @@ class PlaidClient {
2162
2264
  "op-types": opTypesParam(opTypes),
2163
2265
  kinds: kindsParam(kinds),
2164
2266
  order,
2267
+ "ops-limit": opsLimit,
2268
+ "entry-id": entryId,
2165
2269
  },
2166
2270
  }),
2167
2271
  /**
@@ -2377,11 +2481,15 @@ class PlaidClient {
2377
2481
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2378
2482
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2379
2483
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2484
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
2485
+ * operations in `ops` (1..1000). `opCount` on every entry says how
2486
+ * many it has
2487
+ * @param {string} [opts.entryId] - Read only this entry, not a page
2380
2488
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2381
2489
  */
2382
2490
  auditPage: (
2383
2491
  projectId,
2384
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2492
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
2385
2493
  ) =>
2386
2494
  listPage(this, `/api/v1/projects/${projectId}/audit`, {
2387
2495
  limit,
@@ -2392,6 +2500,8 @@ class PlaidClient {
2392
2500
  "op-types": opTypesParam(opTypes),
2393
2501
  kinds: kindsParam(kinds),
2394
2502
  order,
2503
+ "ops-limit": opsLimit,
2504
+ "entry-id": entryId,
2395
2505
  },
2396
2506
  }),
2397
2507
  /**
@@ -2709,8 +2819,11 @@ class PlaidClient {
2709
2819
  * losers are deleted. Links are read when the merge runs, so one made
2710
2820
  * after the caller looked moves too. Every loser must be in the
2711
2821
  * survivor's vocabulary, and a loser that is already gone is skipped, so
2712
- * a repeated merge changes nothing. References to a loser in other
2713
- * entries' metadata are the caller's to rewrite, in the same batch.
2822
+ * a repeated merge changes nothing. Metadata naming a loser by its id,
2823
+ * in the vocabulary's other entries and in the documents of every
2824
+ * project it is linked to, is rewritten to name the survivor. A
2825
+ * reference to a loser in the survivor's own metadata is the caller's to
2826
+ * rewrite, in the same batch.
2714
2827
  * Needs maintainer rights on the vocabulary.
2715
2828
  * @param {string} survivorId - The entry that stays
2716
2829
  * @param {string[]} loserIds - The entries merged into it
@@ -2913,8 +3026,11 @@ class PlaidClient {
2913
3026
  * @param {string} tokenId - The token ID
2914
3027
  * @param {number} position - Code-point offset to split at (strictly between begin and end)
2915
3028
  * @param {string} [auditMessage] - Audit message for this write
2916
- * @param {{id?: string}} [options] - `id`: the right half's id, a UUIDv7 this
2917
- * client minted. A relation layer whose relations must stay inside one token
3029
+ * @param {{id?: string, keep?: "left"|"right"}} [options] - `id`: the new
3030
+ * token's id, a UUIDv7 this client minted. `keep`: the half the original token
3031
+ * (its id, spans, vocab-links, comments and metadata) stays on, `"left"` by
3032
+ * default. With `"right"` the new token, whose id is answered, is the left
3033
+ * half. A relation layer whose relations must stay inside one token
2918
3034
  * of this layer declares a same-ancestor constraint, and the server deletes
2919
3035
  * the relations the split leaves crossing in the same transaction.
2920
3036
  */
@@ -2922,11 +3038,11 @@ class PlaidClient {
2922
3038
  tokenId,
2923
3039
  position,
2924
3040
  auditMessage,
2925
- { id } = {},
3041
+ { id, keep } = {},
2926
3042
  ) =>
2927
3043
  this._request("POST", `/api/v1/tokens/${tokenId}/split`, {
2928
3044
  auditMessage,
2929
- body: bodyOf({ id, position }),
3045
+ body: bodyOf({ id, keep, position }),
2930
3046
  }),
2931
3047
  /**
2932
3048
  * Merge two tokens. The left token (smaller begin) survives with the combined
@@ -3175,9 +3291,16 @@ class PlaidClient {
3175
3291
  */
3176
3292
  // outOfBand: a query is a read that travels as a POST (see the note at
3177
3293
  // the top of http.js). Made on a batch it goes over the wire like any
3178
- // read, and it never joins a logical operation.
3294
+ // read, and it never joins a logical operation. It waits past core's own
3295
+ // query limit, so a query too broad answers with core's 408, and a 503
3296
+ // from core's full queue of large queries is not retried.
3179
3297
  this.query = (body) =>
3180
- this._request("POST", "/api/v1/query", { body, outOfBand: true });
3298
+ this._request("POST", "/api/v1/query", {
3299
+ body,
3300
+ outOfBand: true,
3301
+ timeout: queryTimeout(this),
3302
+ noBusyRetry: true,
3303
+ });
3181
3304
 
3182
3305
  // Logical-operation groups (audit-log grouping). There is no create: a
3183
3306
  // group row is made lazily by the first write carrying `?group-id=`
@@ -3638,6 +3761,7 @@ class PlaidClient {
3638
3761
  * `kind` says what kind of operation this is, for a program reading the
3639
3762
  * log: one of `assistant-plan`, `service-run`, `import`, `bulk-edit`,
3640
3763
  * `guess-adoption`, `repair` or `review` (the server refuses any other).
3764
+ * Any other kind throws here, before anything is written.
3641
3765
  * `ref` is a short string naming what the operation came from, in the
3642
3766
  * shape its kind documents (the core manual, "Kinds of operation"). Both
3643
3767
  * are recorded from the first write like the label, and a nested operation
@@ -3653,10 +3777,15 @@ class PlaidClient {
3653
3777
  * matching end, and the outer numbering resumes after it.
3654
3778
  *
3655
3779
  * @param {string} message - Human label for the operation.
3656
- * @param {object} [opts] - Optional `{ id, kind, ref, keys }`. `id` adopts an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically). `kind`, `ref` and `keys` are described above.
3780
+ * @param {object} [opts] - Optional `{ groupId, kind, ref, keys, minted }`. `groupId` adopts an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically). Python's is `group_id`. `kind`, `ref` and `keys` are described above. `minted` is the ids the operation mints for what it creates: a create refused 409 id-taken for one of them was made by an earlier send of the operation, and answers as made.
3657
3781
  * @returns {string} The operation's group id.
3658
3782
  */
3659
- beginOperation(message, { id, kind, ref, keys, minted } = {}) {
3783
+ beginOperation(message, { groupId, kind, ref, keys, minted } = {}) {
3784
+ if (kind != null && !OPERATION_KINDS.includes(String(kind))) {
3785
+ throw new Error(
3786
+ `Unknown operation kind "${kind}". It is one of: ${OPERATION_KINDS.join(", ")}.`,
3787
+ );
3788
+ }
3660
3789
  const frame = {
3661
3790
  keys: keys || null,
3662
3791
  count: 0,
@@ -3680,7 +3809,7 @@ class PlaidClient {
3680
3809
  return open.id;
3681
3810
  }
3682
3811
  this.operationGroup = {
3683
- id: id || crypto.randomUUID(),
3812
+ id: groupId ? String(groupId) : crypto.randomUUID(),
3684
3813
  message: message == null ? null : String(message),
3685
3814
  kind: kind == null ? null : String(kind),
3686
3815
  ref: ref == null ? null : String(ref),
@@ -3754,11 +3883,11 @@ class PlaidClient {
3754
3883
  *
3755
3884
  * @param {string} message - Human label for the operation.
3756
3885
  * @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
3757
- * @param {object} [opts] - Optional `{ kind, ref, id, keys }`, as for beginOperation.
3886
+ * @param {object} [opts] - Optional `{ kind, ref, groupId, keys, minted }`, as for beginOperation. `kind` is one of `assistant-plan`, `service-run`, `import`, `bulk-edit`, `guess-adoption`, `repair` or `review`, and any other throws before `fn` runs.
3758
3887
  * @returns {Promise<any>} Whatever `fn` resolves to.
3759
3888
  */
3760
- async withOperation(message, fn, { kind, ref, id, keys, minted } = {}) {
3761
- this.beginOperation(message, { kind, ref, id, keys, minted });
3889
+ async withOperation(message, fn, { kind, ref, groupId, keys, minted } = {}) {
3890
+ this.beginOperation(message, { kind, ref, groupId, keys, minted });
3762
3891
  const group = this.operationGroup;
3763
3892
  // The key frame this call opened is ended by this call, whatever else
3764
3893
  // began or ended meanwhile, not by whichever end comes at its depth.
@@ -3854,8 +3983,10 @@ class PlaidClient {
3854
3983
  }
3855
3984
  return res;
3856
3985
  };
3857
- const response = await retryUnknown(() => retryWhileBusy(attempt), {
3986
+ const onRetry = (info) => this._noteRetry(info);
3987
+ const response = await retryUnknown(() => retryWhileBusy(attempt, { onRetry }), {
3858
3988
  delaysMs: this.retryDelaysMs,
3989
+ onRetry,
3859
3990
  }).catch((error) => {
3860
3991
  error.idempotencyKey = key;
3861
3992
  // 401 means the token is missing, expired or invalid: fire the app's
@@ -3902,6 +4033,28 @@ class PlaidClient {
3902
4033
  }
3903
4034
  }
3904
4035
  }
4036
+ // The batch's own header holds each document's version after the whole
4037
+ // batch, the layer rules' remedies at its end included, which no single
4038
+ // operation's answer does: a split that deletes a relation crossing
4039
+ // the new boundary moves the version once more.
4040
+ const outerVersions = response.headers?.get?.("X-Document-Versions");
4041
+ if (outerVersions) {
4042
+ try {
4043
+ const versionsMap = JSON.parse(outerVersions);
4044
+ if (typeof versionsMap === "object" && versionsMap !== null) {
4045
+ this.documentVersions = mergeVersions(
4046
+ this.documentVersions,
4047
+ versionsMap,
4048
+ replayed,
4049
+ );
4050
+ }
4051
+ } catch (e) {
4052
+ console.warn(
4053
+ "Failed to parse document versions header from batch response:",
4054
+ e,
4055
+ );
4056
+ }
4057
+ }
3905
4058
 
3906
4059
  const out = results.map((result) => ({
3907
4060
  ...result,
@@ -3982,7 +4135,8 @@ class PlaidClient {
3982
4135
  * Describe an invite code, with NO authentication — this is what a signup
3983
4136
  * page calls before the redeemer has an account. Returns the kind of link
3984
4137
  * ("signup" or "password-reset"), its status ("active", "used", "expired",
3985
- * "revoked"), and the project it grants access to, if any.
4138
+ * "revoked", or "inactive" when its creator can no longer grant what it
4139
+ * grants), and the project it grants access to, if any.
3986
4140
  *
3987
4141
  * Throws a 404-shaped error if the code is unknown. A known-but-dead code
3988
4142
  * resolves normally with a non-"active" status, so the page can say why.
@@ -4176,7 +4330,7 @@ function constraintMethods(client, kind) {
4176
4330
 
4177
4331
  export default PlaidClient;
4178
4332
  export { PlaidClient };
4179
- export { CONSTRAINT_TYPES, valueSetAllows, violationsOf } from "./constraints.js";
4333
+ export { valueSetAllows, violationsOf } from "./constraints.js";
4180
4334
 
4181
4335
  // Unicode code-point helpers for text offsets (token begin/end are code-point
4182
4336
  // indices). See ./codepoint.js.
@@ -4185,7 +4339,6 @@ export {
4185
4339
  cpSlice,
4186
4340
  cpSlicer,
4187
4341
  utf16ToCp,
4188
- cpToUtf16,
4189
4342
  cpIndexOf,
4190
4343
  } from "./codepoint.js";
4191
4344
  export { composeTextEdits, gapsToOps, applyTextOps } from "./textEdits.js";
@@ -4193,7 +4346,6 @@ export {
4193
4346
  PLAID_NAMESPACE,
4194
4347
  ROLE_KEY,
4195
4348
  PRESERVE_ON_SPLIT_KEY,
4196
- PLAIN_EDITS_KEY,
4197
4349
  SPLIT_ON_SPACE_KEY,
4198
4350
  ROLES,
4199
4351
  readRole,
@@ -4222,11 +4374,7 @@ export { REQUESTED_BY, makeRequester, requesterOf } from "./requester.js";
4222
4374
  // Document locks: `client.documents.locked()` holds one for a block of work and
4223
4375
  // renews it while the block runs. DocumentLockLost is what a write throws once
4224
4376
  // a renewal has failed. See ./documentLock.js.
4225
- export {
4226
- DOCUMENT_LOCK_TTL_MS,
4227
- DocumentLock,
4228
- DocumentLockLost,
4229
- } from "./documentLock.js";
4377
+ export { DocumentLock, DocumentLockLost } from "./documentLock.js";
4230
4378
  // Provenance: the cross-app convention for who made an annotation (flat
4231
4379
  // prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
4232
4380
  // a machine; 'contributed' = a person whose work is reviewed), plus the
@@ -4234,7 +4382,6 @@ export {
4234
4382
  export {
4235
4383
  PROV,
4236
4384
  PROV_STATES,
4237
- PROV_CONFIRMED,
4238
4385
  PROVENANCE_KEYS,
4239
4386
  stampInferred,
4240
4387
  confirmedInferred,
@@ -4244,12 +4391,9 @@ export {
4244
4391
  isMachine,
4245
4392
  isProtected,
4246
4393
  needsReview,
4247
- verifyOnEdit,
4248
4394
  contributeOnEdit,
4249
4395
  serviceSource,
4250
- userSource,
4251
4396
  REVIEW_KEY,
4252
- PROJECT_ROLES,
4253
4397
  readReview,
4254
4398
  projectRole,
4255
4399
  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