@larc-iu/plaid-client 0.2.0-alpha.13 → 0.2.0-alpha.16

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
@@ -5,6 +5,7 @@
5
5
  import { transformRequest, transformResponse } from "./transforms.js";
6
6
  import {
7
7
  makeRequest,
8
+ queueRequest,
8
9
  extractDocumentVersions,
9
10
  parseErrorBody,
10
11
  makeHttpError,
@@ -15,6 +16,7 @@ import {
15
16
  DEFAULT_BATCH_TIMEOUT_MS,
16
17
  } from "./http.js";
17
18
  import { listAll, listPage, iterPages } from "./pagination.js";
19
+ import { withDocumentLock } from "./documentLock.js";
18
20
  import { createSSEConnection } from "./sse.js";
19
21
  import {
20
22
  discoverServices,
@@ -116,6 +118,55 @@ async function anonymousGet(baseUrl, path, options = {}) {
116
118
  */
117
119
  const MAX_BATCH_OPS = 1000;
118
120
 
121
+ /**
122
+ * A batch is the client with a different `_request` (see `queueRequest`) and
123
+ * its own copy of the bundles. Everything else resolves to the client through
124
+ * the prototype: the token and base URL, strict mode and the document versions
125
+ * it tracks, the open logical operation.
126
+ */
127
+ function openBatch(client) {
128
+ const batch = Object.create(client);
129
+ batch.client = client;
130
+ batch.operations = [];
131
+ batch.open = true;
132
+ batch._request = (method, path, options = {}) =>
133
+ queueRequest(batch, method, path, options);
134
+ batch.batch = () => {
135
+ throw new Error("A batch is not nestable: queue on the batch you have");
136
+ };
137
+ batch.batched = batch.batch;
138
+ batch.submit = () => submitBatch(batch);
139
+ batch.abort = () => {
140
+ batch.operations = [];
141
+ batch.open = false;
142
+ };
143
+ batch._installResources();
144
+ return batch;
145
+ }
146
+
147
+ async function submitBatch(batch) {
148
+ if (!batch.open) {
149
+ throw new Error("This batch was already submitted or aborted");
150
+ }
151
+ batch.open = false;
152
+ const ops = batch.operations;
153
+ batch.operations = [];
154
+ if (ops.length === 0) return [];
155
+ const url = `${batch.client.baseUrl}/api/v1/batch`;
156
+ // The server caps a batch at MAX_BATCH_OPS so one transaction cannot hold
157
+ // the write lock without bound. A larger batch goes as consecutive
158
+ // requests, results concatenated in queue order: it could not have been one
159
+ // transaction anyway, and a repair or bulk edit over a big document must
160
+ // not fail on its size alone.
161
+ const results = [];
162
+ for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
163
+ results.push(
164
+ ...(await batch.client._postBatch(url, ops.slice(i, i + MAX_BATCH_OPS))),
165
+ );
166
+ }
167
+ return results;
168
+ }
169
+
119
170
  class PlaidClient {
120
171
  /**
121
172
  * Create a new PlaidClient instance
@@ -141,10 +192,12 @@ class PlaidClient {
141
192
  : options.timeout !== undefined
142
193
  ? options.timeout
143
194
  : DEFAULT_BATCH_TIMEOUT_MS;
144
- this.isBatching = false;
145
- this.batchOperations = [];
146
195
  this.documentVersions = {};
147
196
  this.strictModeDocumentId = null;
197
+ // Set to a DocumentLockLost while a `documents.locked()` block's keep-alive
198
+ // has failed. Every write throws it until the block exits; see
199
+ // documentLock.js.
200
+ this.documentLockLost = null;
148
201
  // The open logical operation (audit-log group), or null. While set, every
149
202
  // write is stamped with `?group-id=` (+ `group-message`) so the audit log
150
203
  // folds them into ONE expandable entry. See beginOperation / withOperation.
@@ -156,8 +209,16 @@ class PlaidClient {
156
209
  this.onAuthError = options.onAuthError || null;
157
210
  this._authErrorFired = false;
158
211
 
159
- // --- API Bundles ---
212
+ this._installResources();
213
+ }
160
214
 
215
+ /**
216
+ * The API bundles (`client.documents`, `client.tokens`, ...). Installed on
217
+ * the client, and again on every batch it opens (see `batch()`): a batch is
218
+ * a view of the client with its own `_request`, so the same bundles built
219
+ * on it queue instead of sending.
220
+ */
221
+ _installResources() {
161
222
  this.vocabLinks = {
162
223
  /**
163
224
  * Create a new vocab link between tokens and a vocab item.
@@ -294,7 +355,6 @@ class PlaidClient {
294
355
  /**
295
356
  * List all vocab layers accessible to user. Transparently follows
296
357
  * pagination cursors and returns the full flat array.
297
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
298
358
  * @param {string} [asOf] - Temporal query timestamp
299
359
  */
300
360
  list: (asOf) =>
@@ -318,7 +378,6 @@ class PlaidClient {
318
378
  * @param {object} [opts]
319
379
  * @param {number} [opts.pageSize] - Per-request page size
320
380
  * @param {string} [opts.asOf] - Temporal query timestamp
321
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
322
381
  * @returns {AsyncGenerator<Array>}
323
382
  */
324
383
  iterPages: ({ pageSize, asOf } = {}) =>
@@ -477,7 +536,7 @@ class PlaidClient {
477
536
  }),
478
537
  /**
479
538
  * Update many relations in a single operation: set values and/or patch metadata.
480
- * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a patch, a null value deleting that key. The relations may lie in several documents of one project; every document touched has its version bumped. An unknown id refuses the whole update.
539
+ * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a patch, a null value deleting that key. The relations may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions`. A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
481
540
  * @returns {Promise<{count: number}>} How many relations were updated.
482
541
  */
483
542
  bulkUpdate: (body, auditMessage) =>
@@ -637,7 +696,7 @@ class PlaidClient {
637
696
  this._request("DELETE", "/api/v1/spans/bulk", { auditMessage, body }),
638
697
  /**
639
698
  * Update many spans in a single operation: set values and/or patch metadata.
640
- * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a patch, a null value deleting that key. The spans may lie in several documents of one project; every document touched has its version bumped. An unknown id refuses the whole update.
699
+ * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a patch, a null value deleting that key. The spans may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions`. A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
641
700
  * @returns {Promise<{count: number}>} How many spans were updated.
642
701
  */
643
702
  bulkUpdate: (body, auditMessage) =>
@@ -690,11 +749,7 @@ class PlaidClient {
690
749
  */
691
750
  info: () => {
692
751
  if (!infoPromise) {
693
- // bypassBatch: a read belongs to whoever asked for it, not to
694
- // whatever batch happens to be open on this shared client.
695
- infoPromise = this._request("GET", "/api/v1/info", {
696
- bypassBatch: true,
697
- }).catch((err) => {
752
+ infoPromise = this._request("GET", "/api/v1/info").catch((err) => {
698
753
  // A failure must not be cached: a client that starts before the
699
754
  // server is up would never see the limits at all.
700
755
  infoPromise = null;
@@ -714,20 +769,6 @@ class PlaidClient {
714
769
  health: () => PlaidClient.health(this.baseUrl, { timeout: this.timeout }),
715
770
  };
716
771
 
717
- this.batch = {
718
- /**
719
- * Execute multiple API operations atomically. If any operation fails, all
720
- * changes are rolled back.
721
- * @param {Array} body - The request body
722
- */
723
- submit: (body, auditMessage) =>
724
- this._request("POST", "/api/v1/batch", {
725
- auditMessage,
726
- body,
727
- noBatch: true,
728
- }),
729
- };
730
-
731
772
  this.texts = {
732
773
  /**
733
774
  * Replace all metadata for a text.
@@ -816,7 +857,6 @@ class PlaidClient {
816
857
  /**
817
858
  * List (or search) users. Transparently follows pagination cursors and
818
859
  * returns the full flat array. Admin-or-maintainer only.
819
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
820
860
  * @param {object} [opts]
821
861
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
822
862
  * @param {string} [opts.asOf] - Temporal query timestamp
@@ -844,7 +884,6 @@ class PlaidClient {
844
884
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
845
885
  * @param {number} [opts.pageSize] - Per-request page size
846
886
  * @param {string} [opts.asOf] - Temporal query timestamp
847
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
848
887
  * @returns {AsyncGenerator<Array>}
849
888
  */
850
889
  iterPages: ({ q, pageSize, asOf } = {}) =>
@@ -874,7 +913,6 @@ class PlaidClient {
874
913
  /**
875
914
  * Get audit log for a user's actions. Transparently follows pagination
876
915
  * cursors and returns the full flat array.
877
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
878
916
  * @param {string} userId - The user ID
879
917
  * @param {string} [startTime] - Start of time range
880
918
  * @param {string} [endTime] - End of time range
@@ -994,7 +1032,6 @@ class PlaidClient {
994
1032
  */
995
1033
  getAvatar: (id) =>
996
1034
  this._request("GET", `/api/v1/users/${id}/avatar`, {
997
- noBatch: true,
998
1035
  binaryResponse: true,
999
1036
  }),
1000
1037
  /**
@@ -1019,32 +1056,79 @@ class PlaidClient {
1019
1056
  * Remove a profile picture. Your own, or anyone's if you are an admin.
1020
1057
  * @param {string} id - The user ID
1021
1058
  */
1059
+ // No flag: the upload beside this one is multipart and cannot be
1060
+ // batched, but a DELETE carries no blob, so the batch transport takes
1061
+ // it. It is an ordinary audited write and queues like any other.
1022
1062
  deleteAvatar: (id, auditMessage) =>
1023
1063
  this._request("DELETE", `/api/v1/users/${id}/avatar`, {
1024
1064
  auditMessage,
1025
- noBatch: true,
1026
1065
  }),
1027
1066
  };
1028
1067
 
1029
1068
  this.userData = {
1030
1069
  /**
1031
1070
  * List a user's private data entries ({key, updatedAt}, plus value when
1032
- * includeValues). Owner or admin only.
1071
+ * includeValues), ordered by key. Owner or admin only. Transparently
1072
+ * follows pagination cursors and returns the full flat array.
1033
1073
  *
1034
1074
  * Narrow with `prefix` (the literal head of a key) and/or `pattern`, a
1035
1075
  * GLOB over the whole key (`*` any run, `?` one character) — the way to
1036
1076
  * ask for a key convention identified by a segment in the middle, e.g.
1037
1077
  * `igt:assistant:*:meta:*` for every conversation's sidebar entry across
1038
1078
  * every project without dragging down the transcripts beside them.
1079
+ *
1080
+ * `pageSize` is exposed here, and defaults lower than elsewhere, because
1081
+ * one value runs to 1 MB: a page of them with `includeValues` is the
1082
+ * largest response this API can be asked for. Raise it when the listing
1083
+ * is keys, or the values are known to be small.
1039
1084
  * @param {string} userId
1040
1085
  * @param {object} [opts]
1041
1086
  * @param {string} [opts.prefix] - Only keys starting with this prefix
1042
1087
  * @param {string} [opts.pattern] - Only keys matching this GLOB
1043
1088
  * @param {boolean} [opts.includeValues] - Also return each entry's value
1089
+ * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1044
1090
  */
1045
- list: (userId, { prefix, pattern, includeValues } = {}) =>
1046
- this._request("GET", `/api/v1/users/${userId}/data`, {
1047
- queryParams: { prefix, pattern, "include-values": includeValues },
1091
+ list: (userId, { prefix, pattern, includeValues, pageSize = 100 } = {}) =>
1092
+ listAll(this, `/api/v1/users/${userId}/data`, {
1093
+ pageSize,
1094
+ query: { prefix, pattern, "include-values": includeValues },
1095
+ }),
1096
+ /**
1097
+ * One page of a user's private data entries, ordered by key.
1098
+ * @param {string} userId
1099
+ * @param {object} [opts]
1100
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
1101
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1102
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1103
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1104
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1105
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1106
+ */
1107
+ listPage: (
1108
+ userId,
1109
+ { prefix, pattern, includeValues, limit, cursor } = {},
1110
+ ) =>
1111
+ listPage(this, `/api/v1/users/${userId}/data`, {
1112
+ limit,
1113
+ cursor,
1114
+ query: { prefix, pattern, "include-values": includeValues },
1115
+ }),
1116
+ /**
1117
+ * Async generator over a user's private data entries, one page at a time.
1118
+ * @param {string} userId
1119
+ * @param {object} [opts]
1120
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
1121
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1122
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1123
+ * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1124
+ */
1125
+ iterPages: (
1126
+ userId,
1127
+ { prefix, pattern, includeValues, pageSize = 100 } = {},
1128
+ ) =>
1129
+ iterPages(this, `/api/v1/users/${userId}/data`, {
1130
+ pageSize,
1131
+ query: { prefix, pattern, "include-values": includeValues },
1048
1132
  }),
1049
1133
  /**
1050
1134
  * Read one private data entry ({key, updatedAt, value}); 404 if absent.
@@ -1098,7 +1182,6 @@ class PlaidClient {
1098
1182
  * List a user's named API tokens. Never includes the signed token
1099
1183
  * string itself — that is only returned once, by create().
1100
1184
  * Transparently follows pagination cursors and returns the full flat array.
1101
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1102
1185
  * @param {string} userId - The user ID who owns the tokens
1103
1186
  */
1104
1187
  list: (userId) => listAll(this, `/api/v1/users/${userId}/tokens`),
@@ -1118,7 +1201,6 @@ class PlaidClient {
1118
1201
  * @param {string} userId - The user ID who owns the tokens
1119
1202
  * @param {object} [opts]
1120
1203
  * @param {number} [opts.pageSize] - Per-request page size
1121
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
1122
1204
  * @returns {AsyncGenerator<Array>}
1123
1205
  */
1124
1206
  iterPages: (userId, { pageSize } = {}) =>
@@ -1154,7 +1236,6 @@ class PlaidClient {
1154
1236
  * maintainer or admin on it. Never includes invite codes — the code is
1155
1237
  * returned once, by create(), and is not recoverable afterward.
1156
1238
  * Transparently follows pagination cursors and returns the full flat array.
1157
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1158
1239
  * @param {object} [opts]
1159
1240
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1160
1241
  * @param {boolean} [opts.all] - List every invite on the server (admin only)
@@ -1184,7 +1265,6 @@ class PlaidClient {
1184
1265
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1185
1266
  * @param {boolean} [opts.all] - List every invite on the server (admin only)
1186
1267
  * @param {number} [opts.pageSize] - Per-request page size
1187
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
1188
1268
  * @returns {AsyncGenerator<Array>}
1189
1269
  */
1190
1270
  iterPages: ({ projectId, all, pageSize } = {}) =>
@@ -1266,8 +1346,12 @@ class PlaidClient {
1266
1346
  * succeeded. Uses VACUUM INTO, which only reads, so it is safe while
1267
1347
  * people are working. Admin only.
1268
1348
  * @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
1349
+ *
1350
+ * outOfBand, as every admin action on the server itself is: none of them
1351
+ * writes project data (see the note at the top of http.js).
1269
1352
  */
1270
- backup: () => this._request("POST", "/api/v1/admin/backup"),
1353
+ backup: () =>
1354
+ this._request("POST", "/api/v1/admin/backup", { outOfBand: true }),
1271
1355
  /**
1272
1356
  * Documents currently held by an editing lock, with who holds each and
1273
1357
  * when it expires on its own. Admin only.
@@ -1280,7 +1364,9 @@ class PlaidClient {
1280
1364
  * @param {string} documentId - The document ID
1281
1365
  */
1282
1366
  releaseLock: (documentId) =>
1283
- this._request("DELETE", `/api/v1/admin/locks/${documentId}`),
1367
+ this._request("DELETE", `/api/v1/admin/locks/${documentId}`, {
1368
+ outOfBand: true,
1369
+ }),
1284
1370
  /**
1285
1371
  * Live login and invite rate-limit buckets: the address, the account
1286
1372
  * where there is one, failures inside the window, the limit, and whether
@@ -1298,18 +1384,43 @@ class PlaidClient {
1298
1384
  */
1299
1385
  clearRateLimits: ({ ip, userId } = {}) =>
1300
1386
  this._request("DELETE", "/api/v1/admin/rate-limits", {
1387
+ outOfBand: true,
1301
1388
  queryParams: { ip, "user-id": userId },
1302
1389
  }),
1303
1390
  /**
1304
- * The tail of the configured log file. Resolves with an `error` string
1305
- * instead of lines when no log file is configured or it does not exist
1306
- * yet. Admin only.
1391
+ * What the server has logged, structured and filtered, from an in-memory
1392
+ * buffer kept whether or not a log file is configured. `requests` is one
1393
+ * entry per HTTP request (method, path, status, duration, and who made
1394
+ * it), `events` is everything else, with a stack trace where there was
1395
+ * one. They are buffered separately so a burst of requests cannot evict
1396
+ * an error. Both come newest first, `matched` counts what passed the
1397
+ * filters, and request `stats` describe the filtered set. Covers Plaid's
1398
+ * own log stream since the last restart: for library messages and older
1399
+ * history, see `logFile`. Admin only.
1400
+ * @param {object} [opts]
1401
+ * @param {number} [opts.limit] - Max entries per kind (default 200, max 2000)
1402
+ * @param {string} [opts.q] - Substring of any field, case-insensitive
1403
+ * @param {string} [opts.level] - Minimum level for events, e.g. "warn"
1404
+ * @param {string} [opts.status] - "2xx".."5xx", an exact code, or "failures"
1405
+ * @param {string} [opts.user] - Only requests made by this account
1406
+ * @param {string} [opts.method] - Only requests with this HTTP method
1407
+ * @returns {Promise<{requests: object, events: object, file: (string|null)}>}
1408
+ */
1409
+ logs: ({ limit, q, level, status, user, method } = {}) =>
1410
+ this._request("GET", "/api/v1/admin/logs", {
1411
+ queryParams: { limit, q, level, status, user, method },
1412
+ }),
1413
+ /**
1414
+ * The tail of the configured log file, as text lines. The only place
1415
+ * third-party library messages and anything from before the last restart
1416
+ * can be read. Resolves with an `error` string instead of lines when no
1417
+ * log file is configured or it does not exist yet. Admin only.
1307
1418
  * @param {object} [opts]
1308
1419
  * @param {number} [opts.lines] - How many lines (default 200, max 2000)
1309
1420
  * @returns {Promise<{file: (string|null), lines: string[], error?: string}>}
1310
1421
  */
1311
- logs: ({ lines } = {}) =>
1312
- this._request("GET", "/api/v1/admin/logs", {
1422
+ logFile: ({ lines } = {}) =>
1423
+ this._request("GET", "/api/v1/admin/logs/file", {
1313
1424
  queryParams: { lines },
1314
1425
  }),
1315
1426
  /**
@@ -1323,7 +1434,6 @@ class PlaidClient {
1323
1434
  * ask for a key convention identified by a segment in the middle, e.g.
1324
1435
  * `igt:assistant:*:meta:*`. Values come only with `includeValues`, and
1325
1436
  * are recased like any other body (see `userData.put`).
1326
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use userDataPage() for a single page in a batch.
1327
1437
  * @param {object} [opts]
1328
1438
  * @param {string} [opts.prefix] - Only keys starting with this
1329
1439
  * @param {string} [opts.pattern] - Only keys matching this GLOB
@@ -1367,7 +1477,6 @@ class PlaidClient {
1367
1477
  * and op-type filter as the per-project read, with the entity scope
1368
1478
  * dropped. Admin only. Transparently follows pagination cursors and
1369
1479
  * returns the full flat array.
1370
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1371
1480
  * @param {object} [opts]
1372
1481
  * @param {string} [opts.startTime] - Only operations at or after this instant
1373
1482
  * @param {string} [opts.endTime] - Only operations at or before this instant
@@ -1405,7 +1514,6 @@ class PlaidClient {
1405
1514
  }),
1406
1515
  /**
1407
1516
  * Async-iterate the instance-wide audit log page by page. Admin only.
1408
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
1409
1517
  * @returns {AsyncGenerator<Array>}
1410
1518
  */
1411
1519
  iterPages: ({ startTime, endTime, opTypes, pageSize } = {}) =>
@@ -1555,21 +1663,68 @@ class PlaidClient {
1555
1663
  queryParams: { "as-of": asOf },
1556
1664
  }),
1557
1665
  /**
1558
- * Acquire or refresh a document lock
1666
+ * Acquire or refresh a document lock.
1667
+ *
1668
+ * outOfBand: the lock is a signal, not project data (see the note at
1669
+ * the top of http.js). Queued on a batch it would be taken only at
1670
+ * submit, after every write it was meant to guard, and until then answer
1671
+ * success to a caller that does not hold it and cannot see the 423
1672
+ * saying somebody else does.
1559
1673
  * @param {string} documentId - The document ID
1560
1674
  */
1561
1675
  acquireLock: (documentId, auditMessage) =>
1562
1676
  this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1563
1677
  auditMessage,
1678
+ outOfBand: true,
1564
1679
  }),
1565
1680
  /**
1566
- * Release a document lock
1681
+ * Release a document lock. outOfBand, for the same reason as
1682
+ * acquireLock: queued, the lock would be held until the batch submits,
1683
+ * and not released at all if it aborts.
1567
1684
  * @param {string} documentId - The document ID
1568
1685
  */
1569
1686
  releaseLock: (documentId, auditMessage) =>
1570
1687
  this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
1571
1688
  auditMessage,
1689
+ outOfBand: true,
1572
1690
  }),
1691
+ /**
1692
+ * Hold this document's server-enforced lock for the length of `fn`,
1693
+ * releasing it on the way out (including on error). Resolves to what
1694
+ * `fn` returned.
1695
+ *
1696
+ * await client.documents.locked(docId, async () => {
1697
+ * // delete + recreate tokens
1698
+ * });
1699
+ *
1700
+ * Wrap any multi-step, server-side mutation of a document that must not
1701
+ * interleave with a human editor or another service, e.g. a parser or
1702
+ * tokenizer that deletes and recreates a document's tokens, spans and
1703
+ * relations. A single atomic call does not need it. While the lock is
1704
+ * held, writes to the document by ANOTHER user are refused with HTTP
1705
+ * 423; the holder's own writes pass and renew it. If another user
1706
+ * already holds it this rejects with a readable 423 and `fn` does not
1707
+ * run.
1708
+ *
1709
+ * The lock is renewed for as long as `fn` runs, so work that computes
1710
+ * for minutes before it writes holds the lock the whole time rather than
1711
+ * only for its first minute. If a renewal fails the lock is gone: every
1712
+ * later write from this client throws `DocumentLockLost`, and a block
1713
+ * that got to the end anyway ends with that error rather than reporting
1714
+ * success. `fn` receives a lock handle and may read `lock.lost` (or call
1715
+ * `lock.raiseIfLost()`) to give up sooner.
1716
+ *
1717
+ * NOT re-entrant: nesting two `locked()` blocks on one document would
1718
+ * release on the inner exit and leave the outer unprotected. Lock at
1719
+ * exactly one level per call path. A lost lock is recorded on the
1720
+ * CLIENT, like batch and strict mode, so it stops every write the client
1721
+ * makes and not only the ones this block makes.
1722
+ * @param {string} documentId - The document ID
1723
+ * @param {(lock: DocumentLock) => any} fn - The work to run while holding it
1724
+ * @param {object} [options] - `{ keepAlive }`: renew on a timer (default true)
1725
+ */
1726
+ locked: (documentId, fn, options) =>
1727
+ withDocumentLock(this, documentId, fn, options),
1573
1728
  /**
1574
1729
  * Get the media file for a document. Media is not versioned, so there is
1575
1730
  * no as-of form: the route refuses the parameter. Prefer the document's
@@ -1578,7 +1733,6 @@ class PlaidClient {
1578
1733
  */
1579
1734
  getMedia: (documentId) =>
1580
1735
  this._request("GET", `/api/v1/documents/${documentId}/media`, {
1581
- noBatch: true,
1582
1736
  binaryResponse: true,
1583
1737
  }),
1584
1738
  /**
@@ -1603,10 +1757,14 @@ class PlaidClient {
1603
1757
  * Delete media file for a document
1604
1758
  * @param {string} documentId - The document ID
1605
1759
  */
1760
+ // The upload above is multipart and cannot be batched, but a DELETE
1761
+ // carries no blob, so the batch transport takes it. It is a write
1762
+ // of the document's own data and queues like any other. Note that the
1763
+ // file removal happens outside the server's transaction, so a batch that
1764
+ // aborts after this op does not bring the file back.
1606
1765
  deleteMedia: (documentId, auditMessage) =>
1607
1766
  this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
1608
1767
  auditMessage,
1609
- noBatch: true,
1610
1768
  }),
1611
1769
  /**
1612
1770
  * Replace all metadata for a document.
@@ -1642,7 +1800,6 @@ class PlaidClient {
1642
1800
  /**
1643
1801
  * Get audit log for a document. Transparently follows pagination cursors
1644
1802
  * and returns the full flat array.
1645
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1646
1803
  * @param {string} documentId - The document ID
1647
1804
  * @param {string} [startTime] - Start of time range
1648
1805
  * @param {string} [endTime] - End of time range
@@ -1866,7 +2023,6 @@ class PlaidClient {
1866
2023
  /**
1867
2024
  * Get audit log for a project. Transparently follows pagination cursors
1868
2025
  * and returns the full flat array.
1869
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1870
2026
  * @param {string} projectId - The project ID
1871
2027
  * @param {string} [startTime] - Start of time range
1872
2028
  * @param {string} [endTime] - End of time range
@@ -1955,7 +2111,6 @@ class PlaidClient {
1955
2111
  /**
1956
2112
  * List all documents in a project. Transparently follows pagination
1957
2113
  * cursors and returns the full flat array.
1958
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1959
2114
  *
1960
2115
  * Note: this endpoint does not support temporal (`as-of`) queries; the
1961
2116
  * server rejects `?as-of=` on the documents-list route with a 400.
@@ -1984,7 +2139,6 @@ class PlaidClient {
1984
2139
  * @param {string} id - The project ID
1985
2140
  * @param {object} [opts]
1986
2141
  * @param {number} [opts.pageSize] - Per-request page size
1987
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
1988
2142
  * @returns {AsyncGenerator<Array>}
1989
2143
  */
1990
2144
  iterDocuments: (id, { pageSize } = {}) =>
@@ -2018,7 +2172,6 @@ class PlaidClient {
2018
2172
  /**
2019
2173
  * List all projects accessible to user. Transparently follows pagination
2020
2174
  * cursors and returns the full flat array.
2021
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
2022
2175
  * @param {string} [asOf] - Temporal query timestamp
2023
2176
  */
2024
2177
  list: (asOf) =>
@@ -2042,7 +2195,6 @@ class PlaidClient {
2042
2195
  * @param {object} [opts]
2043
2196
  * @param {number} [opts.pageSize] - Per-request page size
2044
2197
  * @param {string} [opts.asOf] - Temporal query timestamp
2045
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
2046
2198
  * @returns {AsyncGenerator<Array>}
2047
2199
  */
2048
2200
  iterPages: ({ pageSize, asOf } = {}) =>
@@ -2195,9 +2347,23 @@ class PlaidClient {
2195
2347
  auditMessage,
2196
2348
  body,
2197
2349
  }),
2350
+ /**
2351
+ * Update many vocab items in a single operation: set forms and/or patch
2352
+ * metadata.
2353
+ * @param {Array<{id: string, form?: string, metadata?: any}>} body - `form` is set only when the key is present; `metadata` is a patch, a null value deleting that key. The entries may lie in several vocab layers; the user must have write access to each. An unknown id refuses the whole update, and an id may appear only once. Only an entry whose form really changes restates the documents linking it, and a strict-mode client picks up their new versions from the response.
2354
+ * @returns {Promise<{count: number}>} How many vocab items were updated.
2355
+ */
2356
+ bulkUpdate: (body, auditMessage) =>
2357
+ this._request("PATCH", "/api/v1/vocab-items/bulk", {
2358
+ auditMessage,
2359
+ body,
2360
+ }),
2198
2361
  /**
2199
2362
  * Delete multiple vocab items in a single operation. Each item's
2200
2363
  * descendant vocab links are deleted too. Provide an array of IDs.
2364
+ *
2365
+ * Every document holding one of those links has its version bumped, and
2366
+ * a strict-mode client picks up their new versions from the response.
2201
2367
  * @param {string[]} body - The vocab item IDs to delete
2202
2368
  */
2203
2369
  bulkDelete: (body, auditMessage) =>
@@ -2215,13 +2381,18 @@ class PlaidClient {
2215
2381
  queryParams: { "as-of": asOf },
2216
2382
  }),
2217
2383
  /**
2218
- * Delete a vocab item
2384
+ * Delete a vocab item, and every link to it. Every document holding one
2385
+ * of those links has its version bumped, and a strict-mode client picks
2386
+ * up their new versions from the response.
2219
2387
  * @param {string} id - The resource ID
2220
2388
  */
2221
2389
  delete: (id, auditMessage) =>
2222
2390
  this._request("DELETE", `/api/v1/vocab-items/${id}`, { auditMessage }),
2223
2391
  /**
2224
- * Update a vocab item's form
2392
+ * Update a vocab item's form. A document read carries the entry's form on
2393
+ * every link to it, so a rename restates those documents: each has its
2394
+ * version bumped, and a strict-mode client picks up their new versions
2395
+ * from the response.
2225
2396
  * @param {string} id - The resource ID
2226
2397
  * @param {string} form - The vocab item form
2227
2398
  */
@@ -2399,7 +2570,7 @@ class PlaidClient {
2399
2570
  this._request("DELETE", "/api/v1/tokens/bulk", { auditMessage, body }),
2400
2571
  /**
2401
2572
  * Update many tokens in a single operation: patch metadata.
2402
- * @param {Array} body - Objects of the shape `{id, metadata}`. `metadata` is a patch, a null value deleting that key. The tokens may lie in several documents of one project; every document touched has its version bumped. An unknown id refuses the whole update.
2573
+ * @param {Array} body - Objects of the shape `{id, metadata}`. `metadata` is a patch, a null value deleting that key. The tokens may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions`. A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
2403
2574
  * @returns {Promise<{count: number}>} How many tokens were updated.
2404
2575
  */
2405
2576
  bulkUpdate: (body, auditMessage) =>
@@ -2659,17 +2830,14 @@ class PlaidClient {
2659
2830
  * For 'count': {return: 'count', count}. Entity cells are full entity objects
2660
2831
  * (same shape as the GET endpoints).
2661
2832
  */
2662
- // bypassBatch: a query is a read. `client.batched()` flips one flag on the
2663
- // whole client, so a query issued by unrelated code while a batch is open
2664
- // (the IGT island fetching precedent while a document reconciles) used to
2665
- // be queued into it: the caller got `{batched: true}` instead of results,
2666
- // and the server ran the query against the batch's tx Connection, which
2667
- // 500s and rolls back every write in that batch.
2833
+ // outOfBand: a query is a read that travels as a POST (see the note at
2834
+ // the top of http.js). Made on a batch it goes over the wire like any
2835
+ // read, and it never joins a logical operation.
2668
2836
  this.query = (body, auditMessage) =>
2669
2837
  this._request("POST", "/api/v1/query", {
2670
2838
  auditMessage,
2671
2839
  body,
2672
- bypassBatch: true,
2840
+ outOfBand: true,
2673
2841
  });
2674
2842
 
2675
2843
  // Logical-operation groups (audit-log grouping). There is no create: a
@@ -2739,7 +2907,6 @@ class PlaidClient {
2739
2907
  /**
2740
2908
  * List comments in a project, oldest first. Transparently follows
2741
2909
  * pagination cursors and returns the full flat array.
2742
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching - use listPage() for a single page in a batch.
2743
2910
  * @param {string} projectId - The project to read
2744
2911
  * @param {object} [filters] - Narrow the scope
2745
2912
  * @param {string} [filters.documentId] - Every comment anywhere in one document
@@ -2780,7 +2947,6 @@ class PlaidClient {
2780
2947
  }),
2781
2948
  /**
2782
2949
  * Async-iterate a project's comments page by page; yields each page's entries array.
2783
- * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching - use listPage() for a single page in a batch.
2784
2950
  * @param {string} projectId - The project to read
2785
2951
  * @param {object} [opts]
2786
2952
  * @param {number} [opts.pageSize] - Per-request page size
@@ -2826,7 +2992,6 @@ class PlaidClient {
2826
2992
  * List the comments on a vocabulary's entries, oldest first. Requires
2827
2993
  * read access to the vocabulary. Transparently follows pagination
2828
2994
  * cursors and returns the full flat array.
2829
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching - use listInVocabPage() for a single page in a batch.
2830
2995
  * @param {string} vocabId - The vocab layer to read
2831
2996
  * @param {object} [filters]
2832
2997
  * @param {string} [filters.entityId] - One entry's thread
@@ -2868,6 +3033,106 @@ class PlaidClient {
2868
3033
  },
2869
3034
  ),
2870
3035
  };
3036
+
3037
+ this.guidelines = {
3038
+ /**
3039
+ * Create a guideline in a project. `title` is the handle an assistant
3040
+ * asks for one by, and the only thing a person has to keep current. It is
3041
+ * NOT required to be unique, so a title already in use is written like
3042
+ * any other: warn about it, do not refuse it. A `pinned` guideline is one
3043
+ * the assistant is given in full on every turn.
3044
+ * @param {string} projectId - The project the guideline belongs to
3045
+ * @param {string} title - The handle an assistant asks for one by (1..100 characters)
3046
+ * @param {object} [opts]
3047
+ * @param {string} [opts.body] - The Markdown text (up to 20000 characters; may be empty)
3048
+ * @param {boolean} [opts.pinned] - Send this one to the assistant in full on every turn
3049
+ */
3050
+ create: (projectId, title, { body, pinned } = {}, auditMessage) =>
3051
+ this._request("POST", `/api/v1/projects/${projectId}/guidelines`, {
3052
+ auditMessage,
3053
+ body: bodyOf({ title, body, pinned }),
3054
+ }),
3055
+ /**
3056
+ * Read one guideline, Markdown body included.
3057
+ * @param {string} id - The guideline id
3058
+ */
3059
+ get: (id) => this._request("GET", `/api/v1/guidelines/${id}`),
3060
+ /**
3061
+ * Update a guideline. Every field is optional and an omitted one is
3062
+ * left alone, so an edit to the body need not restate the title.
3063
+ *
3064
+ * Pass `expectedUpdatedAt` (the `updatedAt` you last read) when a person
3065
+ * has been editing prose: the write then fails with 409 rather than
3066
+ * overwriting somebody who saved in between. Leave it off for a pin
3067
+ * toggle or a script, which have nothing of anyone's to lose.
3068
+ * @param {string} id - The guideline id
3069
+ * @param {object} [changes]
3070
+ * @param {string} [changes.title] - The new handle
3071
+ * @param {string} [changes.body] - The new Markdown text
3072
+ * @param {boolean} [changes.pinned] - Whether the assistant always gets it in full
3073
+ * @param {string} [changes.expectedUpdatedAt] - Write only if this is still the stored updatedAt
3074
+ */
3075
+ update: (
3076
+ id,
3077
+ { title, body, pinned, expectedUpdatedAt } = {},
3078
+ auditMessage,
3079
+ ) =>
3080
+ this._request("PATCH", `/api/v1/guidelines/${id}`, {
3081
+ auditMessage,
3082
+ queryParams: { "updated-at": expectedUpdatedAt },
3083
+ body: bodyOf({ title, body, pinned }),
3084
+ }),
3085
+ /**
3086
+ * Delete a guideline.
3087
+ * @param {string} id - The guideline id
3088
+ */
3089
+ delete: (id, auditMessage) =>
3090
+ this._request("DELETE", `/api/v1/guidelines/${id}`, { auditMessage }),
3091
+ /**
3092
+ * List a project's guidelines, by title. Transparently follows
3093
+ * pagination cursors and returns the full flat array.
3094
+ *
3095
+ * Without `includeBodies` each entry carries `bodyChars`, the length of
3096
+ * its body, so a caller can budget before fetching any. Pinned
3097
+ * guidelines are NOT sorted first: `pinned` is on every entry and
3098
+ * grouping is the caller's.
3099
+ * @param {string} projectId - The project to read
3100
+ * @param {object} [opts]
3101
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3102
+ */
3103
+ list: (projectId, { includeBodies } = {}) =>
3104
+ listAll(this, `/api/v1/projects/${projectId}/guidelines`, {
3105
+ query: { "include-bodies": includeBodies },
3106
+ }),
3107
+ /**
3108
+ * Fetch a single page of a project's guidelines.
3109
+ * @param {string} projectId - The project to read
3110
+ * @param {object} [opts]
3111
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
3112
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
3113
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3114
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
3115
+ */
3116
+ listPage: (projectId, { limit, cursor, includeBodies } = {}) =>
3117
+ listPage(this, `/api/v1/projects/${projectId}/guidelines`, {
3118
+ limit,
3119
+ cursor,
3120
+ query: { "include-bodies": includeBodies },
3121
+ }),
3122
+ /**
3123
+ * Async-iterate a project's guidelines page by page; yields each page's entries array.
3124
+ * @param {string} projectId - The project to read
3125
+ * @param {object} [opts]
3126
+ * @param {number} [opts.pageSize] - Per-request page size
3127
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3128
+ * @returns {AsyncGenerator<Array>}
3129
+ */
3130
+ iterPages: (projectId, { pageSize, includeBodies } = {}) =>
3131
+ iterPages(this, `/api/v1/projects/${projectId}/guidelines`, {
3132
+ pageSize,
3133
+ query: { "include-bodies": includeBodies },
3134
+ }),
3135
+ };
2871
3136
  }
2872
3137
 
2873
3138
  // --- Core methods ---
@@ -2909,6 +3174,12 @@ class PlaidClient {
2909
3174
  * write, so an operation that is never ended (crash, closed tab) is still
2910
3175
  * labeled in the log.
2911
3176
  *
3177
+ * "Every write" is the writes of project data. Reads never join, and neither
3178
+ * do the out-of-band signals shaped like a write (a document lock taken or
3179
+ * renewed, a stopped service request, a service reporting itself, an admin
3180
+ * control): none of them is audited, so there would be nothing under the
3181
+ * label.
3182
+ *
2912
3183
  * @param {string} message - Human label for the operation.
2913
3184
  * @param {object} [opts] - Optional `{ id }`: adopt an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically).
2914
3185
  * @returns {string} The operation's group id.
@@ -2963,7 +3234,7 @@ class PlaidClient {
2963
3234
  * callback to refine the label once the outcome is known.
2964
3235
  *
2965
3236
  * await client.withOperation('Merge morphemes', async (setMessage) => {
2966
- * await client.batched(() => { ... });
3237
+ * await client.batched(async (b) => { ... });
2967
3238
  * setMessage(`Merged ${n} morphemes`);
2968
3239
  * });
2969
3240
  *
@@ -2984,53 +3255,29 @@ class PlaidClient {
2984
3255
  }
2985
3256
  }
2986
3257
 
2987
- /** Begin a batch of operations. Subsequent API calls will be queued. */
2988
- beginBatch() {
2989
- this.isBatching = true;
2990
- this.batchOperations = [];
2991
- // Strict mode stamps the expected document-version on the FIRST write of
2992
- // the batch only (see _request) — reset the marker per batch.
2993
- this.batchVersionStamped = false;
2994
- }
2995
-
2996
3258
  /**
2997
- * Submit all queued batch operations as a single batch request, executed
2998
- * atomically. If any operation fails, all changes are rolled back.
2999
- * @returns {Promise<Array>} Array of results corresponding to each operation
3259
+ * Open a batch: a view of this client with the same bundles, on which every
3260
+ * write of project data queues instead of going out. `submit()` sends the
3261
+ * queued operations as ONE atomic request (larger than the server's cap,
3262
+ * as consecutive requests with the results concatenated in queue order) and
3263
+ * resolves to one result per operation; `abort()` drops them. A call made
3264
+ * on the client itself is never touched by an open batch, and a read or an
3265
+ * out-of-band signal made on the batch goes over the wire now (see the note
3266
+ * at the top of `http.js`).
3267
+ *
3268
+ * const b = client.batch();
3269
+ * b.tokens.bulkCreate(sentenceOps);
3270
+ * b.tokens.bulkCreate(wordOps);
3271
+ * const [sentRes, wordRes] = await b.submit();
3272
+ *
3273
+ * Server-side a batch runs sequentially in one transaction: a child op sees
3274
+ * parents created earlier in the same batch, and any op's failure rolls the
3275
+ * whole batch back. A batch is not nestable. Prefer `batched()`, which
3276
+ * submits or aborts for you.
3277
+ * @returns {PlaidClient} the batch view
3000
3278
  */
3001
- async submitBatch() {
3002
- if (!this.isBatching) {
3003
- throw new Error("No active batch. Call beginBatch() first.");
3004
- }
3005
-
3006
- if (this.batchOperations.length === 0) {
3007
- this.isBatching = false;
3008
- return [];
3009
- }
3010
-
3011
- try {
3012
- const url = `${this.baseUrl}/api/v1/batch`;
3013
- const ops = this.batchOperations.map((op) => ({
3014
- path: op.path,
3015
- method: op.method.toUpperCase(),
3016
- ...(op.body && { body: op.body }),
3017
- }));
3018
- // The server caps a batch at MAX_BATCH_OPS so one transaction cannot
3019
- // hold the write lock without bound. A larger batch goes as consecutive
3020
- // requests, results concatenated in queue order: it could not have been
3021
- // one transaction anyway, and a repair or bulk edit over a big document
3022
- // must not fail on its size alone.
3023
- const results = [];
3024
- for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
3025
- results.push(
3026
- ...(await this._postBatch(url, ops.slice(i, i + MAX_BATCH_OPS))),
3027
- );
3028
- }
3029
- return results;
3030
- } finally {
3031
- this.isBatching = false;
3032
- this.batchOperations = [];
3033
- }
3279
+ batch() {
3280
+ return openBatch(this);
3034
3281
  }
3035
3282
 
3036
3283
  /** POST one batch request (at most MAX_BATCH_OPS operations); resolves to its transformed results. */
@@ -3092,50 +3339,31 @@ class PlaidClient {
3092
3339
  }
3093
3340
  }
3094
3341
 
3095
- /** Abort the current batch without executing any operations. */
3096
- abortBatch() {
3097
- this.isBatching = false;
3098
- this.batchOperations = [];
3099
- }
3100
-
3101
- /**
3102
- * Check if currently in batch mode.
3103
- * @returns {boolean}
3104
- */
3105
- isBatchMode() {
3106
- return this.isBatching;
3107
- }
3108
-
3109
3342
  /**
3110
- * Run `fn` with a batch open, then submit all queued ops as ONE atomic
3111
- * request — or abort the batch if `fn` throws, so a half-open batch can never
3112
- * silently swallow later non-batch calls. `fn` makes the (queued) client
3113
- * calls; it must NOT call submitBatch itself. Resolves to the batch results
3114
- * array (`[]` if `fn` queued nothing).
3343
+ * Run `fn` with a batch, then submit all queued ops as ONE atomic request,
3344
+ * or abort the batch if `fn` throws. `fn` receives the batch and makes its
3345
+ * writes on it; it must NOT call `submit()` itself. Resolves to the batch
3346
+ * results array (`[]` if `fn` queued nothing).
3115
3347
  *
3116
- * const [sentRes, wordRes] = await client.batched(async () => {
3117
- * client.tokens.bulkCreate(sentenceOps);
3118
- * client.tokens.bulkCreate(wordOps);
3348
+ * const [sentRes, wordRes] = await client.batched(async (b) => {
3349
+ * b.tokens.bulkCreate(sentenceOps);
3350
+ * b.tokens.bulkCreate(wordOps);
3119
3351
  * });
3120
3352
  *
3121
- * Server-side a batch runs sequentially in one transaction (a child op sees
3122
- * parents created earlier in the same `fn`; any op's failure rolls the whole
3123
- * batch back). Not nestable. Named `batched()` because `client.batch` is the
3124
- * low-level batch resource.
3125
- * @param {() => (void | Promise<void>)} fn
3353
+ * A write made on `client` inside `fn` is not part of the batch: it goes
3354
+ * over the wire at once, as it would anywhere else. See `batch()`.
3355
+ * @param {(batch: PlaidClient) => (void | Promise<void>)} fn
3126
3356
  * @returns {Promise<Array>}
3127
3357
  */
3128
3358
  async batched(fn) {
3129
- this.beginBatch();
3359
+ const batch = this.batch();
3130
3360
  try {
3131
- await fn();
3361
+ await fn(batch);
3132
3362
  } catch (e) {
3133
- // fn failed: drop the queued ops so the client leaves batch mode and
3134
- // later plain calls don't queue into a never-submitted batch.
3135
- if (this.isBatchMode()) this.abortBatch();
3363
+ batch.abort();
3136
3364
  throw e;
3137
3365
  }
3138
- return this.submitBatch();
3366
+ return batch.submit();
3139
3367
  }
3140
3368
 
3141
3369
  /**
@@ -3313,6 +3541,14 @@ export {
3313
3541
  // request, and `critical()` holds that off around writes. See ./services.js
3314
3542
  // and the manual, "Stopping a request".
3315
3543
  export { ServiceCancelled, createCancelScope } from "./services.js";
3544
+ // Document locks: `client.documents.locked()` holds one for a block of work and
3545
+ // renews it while the block runs. DocumentLockLost is what a write throws once
3546
+ // a renewal has failed. See ./documentLock.js.
3547
+ export {
3548
+ DOCUMENT_LOCK_TTL_MS,
3549
+ DocumentLock,
3550
+ DocumentLockLost,
3551
+ } from "./documentLock.js";
3316
3552
  // Provenance: the cross-app convention for who made an annotation (flat
3317
3553
  // prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
3318
3554
  // a machine; 'contributed' = a person whose work is reviewed), plus the