@larc-iu/plaid-client 0.2.0-alpha.12 → 0.2.0-alpha.14

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
@@ -15,6 +15,7 @@ import {
15
15
  DEFAULT_BATCH_TIMEOUT_MS,
16
16
  } from "./http.js";
17
17
  import { listAll, listPage, iterPages } from "./pagination.js";
18
+ import { withDocumentLock } from "./documentLock.js";
18
19
  import { createSSEConnection } from "./sse.js";
19
20
  import {
20
21
  discoverServices,
@@ -145,6 +146,10 @@ class PlaidClient {
145
146
  this.batchOperations = [];
146
147
  this.documentVersions = {};
147
148
  this.strictModeDocumentId = null;
149
+ // Set to a DocumentLockLost while a `documents.locked()` block's keep-alive
150
+ // has failed. Every write throws it until the block exits; see
151
+ // documentLock.js.
152
+ this.documentLockLost = null;
148
153
  // The open logical operation (audit-log group), or null. While set, every
149
154
  // write is stamped with `?group-id=` (+ `group-message`) so the audit log
150
155
  // folds them into ONE expandable entry. See beginOperation / withOperation.
@@ -229,6 +234,7 @@ class PlaidClient {
229
234
  */
230
235
  get: (id, asOf) =>
231
236
  this._request("GET", `/api/v1/vocab-links/${id}`, {
237
+ bypassBatch: true,
232
238
  queryParams: { "as-of": asOf },
233
239
  }),
234
240
  /**
@@ -248,6 +254,7 @@ class PlaidClient {
248
254
  */
249
255
  get: (id, includeItems, asOf) =>
250
256
  this._request("GET", `/api/v1/vocab-layers/${id}`, {
257
+ bypassBatch: true,
251
258
  queryParams: { "include-items": includeItems, "as-of": asOf },
252
259
  }),
253
260
  /**
@@ -294,7 +301,6 @@ class PlaidClient {
294
301
  /**
295
302
  * List all vocab layers accessible to user. Transparently follows
296
303
  * 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
304
  * @param {string} [asOf] - Temporal query timestamp
299
305
  */
300
306
  list: (asOf) =>
@@ -318,7 +324,6 @@ class PlaidClient {
318
324
  * @param {object} [opts]
319
325
  * @param {number} [opts.pageSize] - Per-request page size
320
326
  * @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
327
  * @returns {AsyncGenerator<Array>}
323
328
  */
324
329
  iterPages: ({ pageSize, asOf } = {}) =>
@@ -408,6 +413,7 @@ class PlaidClient {
408
413
  */
409
414
  get: (relationId, asOf) =>
410
415
  this._request("GET", `/api/v1/relations/${relationId}`, {
416
+ bypassBatch: true,
411
417
  queryParams: { "as-of": asOf },
412
418
  }),
413
419
  /**
@@ -475,6 +481,16 @@ class PlaidClient {
475
481
  auditMessage,
476
482
  body,
477
483
  }),
484
+ /**
485
+ * Update many relations in a single operation: set values and/or patch metadata.
486
+ * @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.
487
+ * @returns {Promise<{count: number}>} How many relations were updated.
488
+ */
489
+ bulkUpdate: (body, auditMessage) =>
490
+ this._request("PATCH", "/api/v1/relations/bulk", {
491
+ auditMessage,
492
+ body,
493
+ }),
478
494
  };
479
495
 
480
496
  this.spanLayers = {
@@ -516,6 +532,7 @@ class PlaidClient {
516
532
  */
517
533
  get: (spanLayerId, asOf) =>
518
534
  this._request("GET", `/api/v1/span-layers/${spanLayerId}`, {
535
+ bypassBatch: true,
519
536
  queryParams: { "as-of": asOf },
520
537
  }),
521
538
  /**
@@ -594,6 +611,7 @@ class PlaidClient {
594
611
  */
595
612
  get: (spanId, asOf) =>
596
613
  this._request("GET", `/api/v1/spans/${spanId}`, {
614
+ bypassBatch: true,
597
615
  queryParams: { "as-of": asOf },
598
616
  }),
599
617
  /**
@@ -625,6 +643,16 @@ class PlaidClient {
625
643
  */
626
644
  bulkDelete: (body, auditMessage) =>
627
645
  this._request("DELETE", "/api/v1/spans/bulk", { auditMessage, body }),
646
+ /**
647
+ * Update many spans in a single operation: set values and/or patch metadata.
648
+ * @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.
649
+ * @returns {Promise<{count: number}>} How many spans were updated.
650
+ */
651
+ bulkUpdate: (body, auditMessage) =>
652
+ this._request("PATCH", "/api/v1/spans/bulk", {
653
+ auditMessage,
654
+ body,
655
+ }),
628
656
  /**
629
657
  * Replace all metadata for a span.
630
658
  * @param {string} spanId - The span ID
@@ -765,6 +793,7 @@ class PlaidClient {
765
793
  */
766
794
  get: (textId, asOf) =>
767
795
  this._request("GET", `/api/v1/texts/${textId}`, {
796
+ bypassBatch: true,
768
797
  queryParams: { "as-of": asOf },
769
798
  }),
770
799
  /**
@@ -796,7 +825,6 @@ class PlaidClient {
796
825
  /**
797
826
  * List (or search) users. Transparently follows pagination cursors and
798
827
  * returns the full flat array. Admin-or-maintainer only.
799
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
800
828
  * @param {object} [opts]
801
829
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
802
830
  * @param {string} [opts.asOf] - Temporal query timestamp
@@ -824,7 +852,6 @@ class PlaidClient {
824
852
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
825
853
  * @param {number} [opts.pageSize] - Per-request page size
826
854
  * @param {string} [opts.asOf] - Temporal query timestamp
827
- * 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.
828
855
  * @returns {AsyncGenerator<Array>}
829
856
  */
830
857
  iterPages: ({ q, pageSize, asOf } = {}) =>
@@ -854,7 +881,6 @@ class PlaidClient {
854
881
  /**
855
882
  * Get audit log for a user's actions. Transparently follows pagination
856
883
  * cursors and returns the full flat array.
857
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
858
884
  * @param {string} userId - The user ID
859
885
  * @param {string} [startTime] - Start of time range
860
886
  * @param {string} [endTime] - End of time range
@@ -905,6 +931,7 @@ class PlaidClient {
905
931
  */
906
932
  get: (id, asOf) =>
907
933
  this._request("GET", `/api/v1/users/${id}`, {
934
+ bypassBatch: true,
908
935
  queryParams: { "as-of": asOf },
909
936
  }),
910
937
  /**
@@ -974,7 +1001,7 @@ class PlaidClient {
974
1001
  */
975
1002
  getAvatar: (id) =>
976
1003
  this._request("GET", `/api/v1/users/${id}/avatar`, {
977
- noBatch: true,
1004
+ bypassBatch: true,
978
1005
  binaryResponse: true,
979
1006
  }),
980
1007
  /**
@@ -999,25 +1026,86 @@ class PlaidClient {
999
1026
  * Remove a profile picture. Your own, or anyone's if you are an admin.
1000
1027
  * @param {string} id - The user ID
1001
1028
  */
1029
+ // No flag: the upload beside this one is multipart and cannot be
1030
+ // batched, but a DELETE carries no blob, so the batch transport takes
1031
+ // it. It is an ordinary audited write and queues like any other.
1002
1032
  deleteAvatar: (id, auditMessage) =>
1003
1033
  this._request("DELETE", `/api/v1/users/${id}/avatar`, {
1004
1034
  auditMessage,
1005
- noBatch: true,
1006
1035
  }),
1007
1036
  };
1008
1037
 
1009
1038
  this.userData = {
1010
1039
  /**
1011
1040
  * List a user's private data entries ({key, updatedAt}, plus value when
1012
- * includeValues). Owner or admin only.
1041
+ * includeValues), ordered by key. Owner or admin only. Transparently
1042
+ * follows pagination cursors and returns the full flat array.
1043
+ *
1044
+ * Narrow with `prefix` (the literal head of a key) and/or `pattern`, a
1045
+ * GLOB over the whole key (`*` any run, `?` one character) — the way to
1046
+ * ask for a key convention identified by a segment in the middle, e.g.
1047
+ * `igt:assistant:*:meta:*` for every conversation's sidebar entry across
1048
+ * every project without dragging down the transcripts beside them.
1049
+ *
1050
+ * `pageSize` is exposed here, and defaults lower than elsewhere, because
1051
+ * one value runs to 1 MB: a page of them with `includeValues` is the
1052
+ * largest response this API can be asked for. Raise it when the listing
1053
+ * is keys, or the values are known to be small.
1054
+ * @param {string} userId
1055
+ * @param {object} [opts]
1056
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
1057
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1058
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1059
+ * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1060
+ */
1061
+ list: (userId, { prefix, pattern, includeValues, pageSize = 100 } = {}) =>
1062
+ // Every page carries bypassBatch (the pagination helpers set it): a
1063
+ // read belongs to whoever asked for it, not to whatever batch happens
1064
+ // to be open on this shared client. The assistant panel is app chrome
1065
+ // in both SPAs, so it reads this store while an import or a bulk edit
1066
+ // holds a batch open. Queued, the read answered `{batched: true}` (the
1067
+ // sidebar then threw on `.map`) and took a slot in the batch's
1068
+ // results, shifting every index the caller counted on.
1069
+ listAll(this, `/api/v1/users/${userId}/data`, {
1070
+ pageSize,
1071
+ query: { prefix, pattern, "include-values": includeValues },
1072
+ }),
1073
+ /**
1074
+ * One page of a user's private data entries, ordered by key.
1075
+ * @param {string} userId
1076
+ * @param {object} [opts]
1077
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
1078
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1079
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1080
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1081
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1082
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1083
+ */
1084
+ listPage: (
1085
+ userId,
1086
+ { prefix, pattern, includeValues, limit, cursor } = {},
1087
+ ) =>
1088
+ listPage(this, `/api/v1/users/${userId}/data`, {
1089
+ limit,
1090
+ cursor,
1091
+ query: { prefix, pattern, "include-values": includeValues },
1092
+ }),
1093
+ /**
1094
+ * Async generator over a user's private data entries, one page at a time.
1013
1095
  * @param {string} userId
1014
1096
  * @param {object} [opts]
1015
1097
  * @param {string} [opts.prefix] - Only keys starting with this prefix
1098
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1016
1099
  * @param {boolean} [opts.includeValues] - Also return each entry's value
1100
+ * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1017
1101
  */
1018
- list: (userId, { prefix, includeValues } = {}) =>
1019
- this._request("GET", `/api/v1/users/${userId}/data`, {
1020
- queryParams: { prefix, "include-values": includeValues },
1102
+ iterPages: (
1103
+ userId,
1104
+ { prefix, pattern, includeValues, pageSize = 100 } = {},
1105
+ ) =>
1106
+ iterPages(this, `/api/v1/users/${userId}/data`, {
1107
+ pageSize,
1108
+ query: { prefix, pattern, "include-values": includeValues },
1021
1109
  }),
1022
1110
  /**
1023
1111
  * Read one private data entry ({key, updatedAt, value}); 404 if absent.
@@ -1025,9 +1113,11 @@ class PlaidClient {
1025
1113
  * @param {string} key
1026
1114
  */
1027
1115
  get: (userId, key) =>
1116
+ // bypassBatch for the same reason as list() above.
1028
1117
  this._request(
1029
1118
  "GET",
1030
1119
  `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1120
+ { bypassBatch: true },
1031
1121
  ),
1032
1122
  /**
1033
1123
  * Create or replace one private data entry. `value` is any JSON (up to
@@ -1071,7 +1161,6 @@ class PlaidClient {
1071
1161
  * List a user's named API tokens. Never includes the signed token
1072
1162
  * string itself — that is only returned once, by create().
1073
1163
  * Transparently follows pagination cursors and returns the full flat array.
1074
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1075
1164
  * @param {string} userId - The user ID who owns the tokens
1076
1165
  */
1077
1166
  list: (userId) => listAll(this, `/api/v1/users/${userId}/tokens`),
@@ -1091,7 +1180,6 @@ class PlaidClient {
1091
1180
  * @param {string} userId - The user ID who owns the tokens
1092
1181
  * @param {object} [opts]
1093
1182
  * @param {number} [opts.pageSize] - Per-request page size
1094
- * 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.
1095
1183
  * @returns {AsyncGenerator<Array>}
1096
1184
  */
1097
1185
  iterPages: (userId, { pageSize } = {}) =>
@@ -1127,7 +1215,6 @@ class PlaidClient {
1127
1215
  * maintainer or admin on it. Never includes invite codes — the code is
1128
1216
  * returned once, by create(), and is not recoverable afterward.
1129
1217
  * Transparently follows pagination cursors and returns the full flat array.
1130
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1131
1218
  * @param {object} [opts]
1132
1219
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1133
1220
  * @param {boolean} [opts.all] - List every invite on the server (admin only)
@@ -1157,7 +1244,6 @@ class PlaidClient {
1157
1244
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1158
1245
  * @param {boolean} [opts.all] - List every invite on the server (admin only)
1159
1246
  * @param {number} [opts.pageSize] - Per-request page size
1160
- * 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.
1161
1247
  * @returns {AsyncGenerator<Array>}
1162
1248
  */
1163
1249
  iterPages: ({ projectId, all, pageSize } = {}) =>
@@ -1232,35 +1318,48 @@ class PlaidClient {
1232
1318
  * walks the media directory, so open it, do not poll it. Admin only.
1233
1319
  * @returns {Promise<{version: string, jvm: object, database: object, media: object, backup: object, settings: object}>}
1234
1320
  */
1235
- server: () => this._request("GET", "/api/v1/admin/server"),
1321
+ server: () =>
1322
+ this._request("GET", "/api/v1/admin/server", { bypassBatch: true }),
1236
1323
  /**
1237
1324
  * Take a database backup right now, outside the nightly schedule.
1238
1325
  * Resolves to the backup block with `ok` reporting whether the snapshot
1239
1326
  * succeeded. Uses VACUUM INTO, which only reads, so it is safe while
1240
1327
  * people are working. Admin only.
1241
1328
  * @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
1329
+ *
1330
+ * Goes over the wire even while a batch is open, as every admin action
1331
+ * on the server itself does: none of them writes project data, and a
1332
+ * backup taken inside somebody's open write transaction is not what the
1333
+ * caller asked for.
1242
1334
  */
1243
- backup: () => this._request("POST", "/api/v1/admin/backup"),
1335
+ backup: () =>
1336
+ this._request("POST", "/api/v1/admin/backup", { bypassBatch: true }),
1244
1337
  /**
1245
1338
  * Documents currently held by an editing lock, with who holds each and
1246
1339
  * when it expires on its own. Admin only.
1247
1340
  * @returns {Promise<{entries: Array<{documentId: string, userId: string, expiresAt: number}>}>}
1248
1341
  */
1249
- locks: () => this._request("GET", "/api/v1/admin/locks"),
1342
+ locks: () =>
1343
+ this._request("GET", "/api/v1/admin/locks", { bypassBatch: true }),
1250
1344
  /**
1251
1345
  * Drop the lock on a document whoever holds it. Idempotent. For a client
1252
1346
  * that went away without releasing one. Admin only.
1253
1347
  * @param {string} documentId - The document ID
1254
1348
  */
1255
1349
  releaseLock: (documentId) =>
1256
- this._request("DELETE", `/api/v1/admin/locks/${documentId}`),
1350
+ this._request("DELETE", `/api/v1/admin/locks/${documentId}`, {
1351
+ bypassBatch: true,
1352
+ }),
1257
1353
  /**
1258
1354
  * Live login and invite rate-limit buckets: the address, the account
1259
1355
  * where there is one, failures inside the window, the limit, and whether
1260
1356
  * it is currently blocking. Admin only.
1261
1357
  * @returns {Promise<{windowMs: number, logins: Array, ips: Array, invites: Array}>}
1262
1358
  */
1263
- rateLimits: () => this._request("GET", "/api/v1/admin/rate-limits"),
1359
+ rateLimits: () =>
1360
+ this._request("GET", "/api/v1/admin/rate-limits", {
1361
+ bypassBatch: true,
1362
+ }),
1264
1363
  /**
1265
1364
  * Forget recorded rate-limit failures. With `ip`, clears that address,
1266
1365
  * narrowed to one account with `userId`. With neither, clears every
@@ -1271,18 +1370,45 @@ class PlaidClient {
1271
1370
  */
1272
1371
  clearRateLimits: ({ ip, userId } = {}) =>
1273
1372
  this._request("DELETE", "/api/v1/admin/rate-limits", {
1373
+ bypassBatch: true,
1274
1374
  queryParams: { ip, "user-id": userId },
1275
1375
  }),
1276
1376
  /**
1277
- * The tail of the configured log file. Resolves with an `error` string
1278
- * instead of lines when no log file is configured or it does not exist
1279
- * yet. Admin only.
1377
+ * What the server has logged, structured and filtered, from an in-memory
1378
+ * buffer kept whether or not a log file is configured. `requests` is one
1379
+ * entry per HTTP request (method, path, status, duration, and who made
1380
+ * it), `events` is everything else, with a stack trace where there was
1381
+ * one. They are buffered separately so a burst of requests cannot evict
1382
+ * an error. Both come newest first, `matched` counts what passed the
1383
+ * filters, and request `stats` describe the filtered set. Covers Plaid's
1384
+ * own log stream since the last restart: for library messages and older
1385
+ * history, see `logFile`. Admin only.
1386
+ * @param {object} [opts]
1387
+ * @param {number} [opts.limit] - Max entries per kind (default 200, max 2000)
1388
+ * @param {string} [opts.q] - Substring of any field, case-insensitive
1389
+ * @param {string} [opts.level] - Minimum level for events, e.g. "warn"
1390
+ * @param {string} [opts.status] - "2xx".."5xx", an exact code, or "failures"
1391
+ * @param {string} [opts.user] - Only requests made by this account
1392
+ * @param {string} [opts.method] - Only requests with this HTTP method
1393
+ * @returns {Promise<{requests: object, events: object, file: (string|null)}>}
1394
+ */
1395
+ logs: ({ limit, q, level, status, user, method } = {}) =>
1396
+ this._request("GET", "/api/v1/admin/logs", {
1397
+ bypassBatch: true,
1398
+ queryParams: { limit, q, level, status, user, method },
1399
+ }),
1400
+ /**
1401
+ * The tail of the configured log file, as text lines. The only place
1402
+ * third-party library messages and anything from before the last restart
1403
+ * can be read. Resolves with an `error` string instead of lines when no
1404
+ * log file is configured or it does not exist yet. Admin only.
1280
1405
  * @param {object} [opts]
1281
1406
  * @param {number} [opts.lines] - How many lines (default 200, max 2000)
1282
1407
  * @returns {Promise<{file: (string|null), lines: string[], error?: string}>}
1283
1408
  */
1284
- logs: ({ lines } = {}) =>
1285
- this._request("GET", "/api/v1/admin/logs", {
1409
+ logFile: ({ lines } = {}) =>
1410
+ this._request("GET", "/api/v1/admin/logs/file", {
1411
+ bypassBatch: true,
1286
1412
  queryParams: { lines },
1287
1413
  }),
1288
1414
  /**
@@ -1296,7 +1422,6 @@ class PlaidClient {
1296
1422
  * ask for a key convention identified by a segment in the middle, e.g.
1297
1423
  * `igt:assistant:*:meta:*`. Values come only with `includeValues`, and
1298
1424
  * are recased like any other body (see `userData.put`).
1299
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use userDataPage() for a single page in a batch.
1300
1425
  * @param {object} [opts]
1301
1426
  * @param {string} [opts.prefix] - Only keys starting with this
1302
1427
  * @param {string} [opts.pattern] - Only keys matching this GLOB
@@ -1340,7 +1465,6 @@ class PlaidClient {
1340
1465
  * and op-type filter as the per-project read, with the entity scope
1341
1466
  * dropped. Admin only. Transparently follows pagination cursors and
1342
1467
  * returns the full flat array.
1343
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1344
1468
  * @param {object} [opts]
1345
1469
  * @param {string} [opts.startTime] - Only operations at or after this instant
1346
1470
  * @param {string} [opts.endTime] - Only operations at or before this instant
@@ -1378,7 +1502,6 @@ class PlaidClient {
1378
1502
  }),
1379
1503
  /**
1380
1504
  * Async-iterate the instance-wide audit log page by page. Admin only.
1381
- * 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.
1382
1505
  * @returns {AsyncGenerator<Array>}
1383
1506
  */
1384
1507
  iterPages: ({ startTime, endTime, opTypes, pageSize } = {}) =>
@@ -1413,6 +1536,7 @@ class PlaidClient {
1413
1536
  ? `/api/v1/projects/${projectId}/audit/tally`
1414
1537
  : "/api/v1/audit/tally";
1415
1538
  const result = await this._request("GET", path, {
1539
+ bypassBatch: true,
1416
1540
  queryParams: {
1417
1541
  "start-time": startTime,
1418
1542
  "end-time": endTime,
@@ -1495,6 +1619,7 @@ class PlaidClient {
1495
1619
  */
1496
1620
  get: (tokenLayerId, asOf) =>
1497
1621
  this._request("GET", `/api/v1/token-layers/${tokenLayerId}`, {
1622
+ bypassBatch: true,
1498
1623
  queryParams: { "as-of": asOf },
1499
1624
  }),
1500
1625
  /**
@@ -1525,24 +1650,72 @@ class PlaidClient {
1525
1650
  */
1526
1651
  checkLock: (documentId, asOf) =>
1527
1652
  this._request("GET", `/api/v1/documents/${documentId}/lock`, {
1653
+ bypassBatch: true,
1528
1654
  queryParams: { "as-of": asOf },
1529
1655
  }),
1530
1656
  /**
1531
- * Acquire or refresh a document lock
1657
+ * Acquire or refresh a document lock.
1658
+ *
1659
+ * Goes over the wire even while a batch is open. The lock is an
1660
+ * out-of-band signal, not project data: a queued acquire is taken only
1661
+ * when the batch submits, which is after every write it was meant to
1662
+ * guard, and until then it answers success to a caller that does not
1663
+ * hold it and cannot see the 423 saying somebody else does.
1532
1664
  * @param {string} documentId - The document ID
1533
1665
  */
1534
1666
  acquireLock: (documentId, auditMessage) =>
1535
1667
  this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1536
1668
  auditMessage,
1669
+ bypassBatch: true,
1537
1670
  }),
1538
1671
  /**
1539
- * Release a document lock
1672
+ * Release a document lock. Goes over the wire even while a batch is
1673
+ * open, for the same reason as acquireLock: queued, the lock is held
1674
+ * until the batch submits, and not released at all if it aborts.
1540
1675
  * @param {string} documentId - The document ID
1541
1676
  */
1542
1677
  releaseLock: (documentId, auditMessage) =>
1543
1678
  this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
1544
1679
  auditMessage,
1680
+ bypassBatch: true,
1545
1681
  }),
1682
+ /**
1683
+ * Hold this document's server-enforced lock for the length of `fn`,
1684
+ * releasing it on the way out (including on error). Resolves to what
1685
+ * `fn` returned.
1686
+ *
1687
+ * await client.documents.locked(docId, async () => {
1688
+ * // delete + recreate tokens
1689
+ * });
1690
+ *
1691
+ * Wrap any multi-step, server-side mutation of a document that must not
1692
+ * interleave with a human editor or another service, e.g. a parser or
1693
+ * tokenizer that deletes and recreates a document's tokens, spans and
1694
+ * relations. A single atomic call does not need it. While the lock is
1695
+ * held, writes to the document by ANOTHER user are refused with HTTP
1696
+ * 423; the holder's own writes pass and renew it. If another user
1697
+ * already holds it this rejects with a readable 423 and `fn` does not
1698
+ * run.
1699
+ *
1700
+ * The lock is renewed for as long as `fn` runs, so work that computes
1701
+ * for minutes before it writes holds the lock the whole time rather than
1702
+ * only for its first minute. If a renewal fails the lock is gone: every
1703
+ * later write from this client throws `DocumentLockLost`, and a block
1704
+ * that got to the end anyway ends with that error rather than reporting
1705
+ * success. `fn` receives a lock handle and may read `lock.lost` (or call
1706
+ * `lock.raiseIfLost()`) to give up sooner.
1707
+ *
1708
+ * NOT re-entrant: nesting two `locked()` blocks on one document would
1709
+ * release on the inner exit and leave the outer unprotected. Lock at
1710
+ * exactly one level per call path. A lost lock is recorded on the
1711
+ * CLIENT, like batch and strict mode, so it stops every write the client
1712
+ * makes and not only the ones this block makes.
1713
+ * @param {string} documentId - The document ID
1714
+ * @param {(lock: DocumentLock) => any} fn - The work to run while holding it
1715
+ * @param {object} [options] - `{ keepAlive }`: renew on a timer (default true)
1716
+ */
1717
+ locked: (documentId, fn, options) =>
1718
+ withDocumentLock(this, documentId, fn, options),
1546
1719
  /**
1547
1720
  * Get the media file for a document. Media is not versioned, so there is
1548
1721
  * no as-of form: the route refuses the parameter. Prefer the document's
@@ -1551,7 +1724,7 @@ class PlaidClient {
1551
1724
  */
1552
1725
  getMedia: (documentId) =>
1553
1726
  this._request("GET", `/api/v1/documents/${documentId}/media`, {
1554
- noBatch: true,
1727
+ bypassBatch: true,
1555
1728
  binaryResponse: true,
1556
1729
  }),
1557
1730
  /**
@@ -1576,10 +1749,14 @@ class PlaidClient {
1576
1749
  * Delete media file for a document
1577
1750
  * @param {string} documentId - The document ID
1578
1751
  */
1752
+ // No flag: the upload above is multipart and cannot be batched, but a
1753
+ // DELETE carries no blob, so the batch transport takes it. It is a write
1754
+ // of the document's own data and queues like any other. Note that the
1755
+ // file removal happens outside the server's transaction, so a batch that
1756
+ // aborts after this op does not bring the file back.
1579
1757
  deleteMedia: (documentId, auditMessage) =>
1580
1758
  this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
1581
1759
  auditMessage,
1582
- noBatch: true,
1583
1760
  }),
1584
1761
  /**
1585
1762
  * Replace all metadata for a document.
@@ -1615,7 +1792,6 @@ class PlaidClient {
1615
1792
  /**
1616
1793
  * Get audit log for a document. Transparently follows pagination cursors
1617
1794
  * and returns the full flat array.
1618
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1619
1795
  * @param {string} documentId - The document ID
1620
1796
  * @param {string} [startTime] - Start of time range
1621
1797
  * @param {string} [endTime] - End of time range
@@ -1698,6 +1874,7 @@ class PlaidClient {
1698
1874
  */
1699
1875
  get: (documentId, includeBody, asOf, layers) =>
1700
1876
  this._request("GET", `/api/v1/documents/${documentId}`, {
1877
+ bypassBatch: true,
1701
1878
  queryParams: {
1702
1879
  "include-body": includeBody,
1703
1880
  "as-of": asOf,
@@ -1839,7 +2016,6 @@ class PlaidClient {
1839
2016
  /**
1840
2017
  * Get audit log for a project. Transparently follows pagination cursors
1841
2018
  * and returns the full flat array.
1842
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1843
2019
  * @param {string} projectId - The project ID
1844
2020
  * @param {string} [startTime] - Start of time range
1845
2021
  * @param {string} [endTime] - End of time range
@@ -1895,6 +2071,7 @@ class PlaidClient {
1895
2071
  */
1896
2072
  myLastEdits: (projectId) =>
1897
2073
  this._request("GET", `/api/v1/projects/${projectId}/audit/last-edits`, {
2074
+ bypassBatch: true,
1898
2075
  skipResponseTransform: true,
1899
2076
  }),
1900
2077
  /**
@@ -1923,12 +2100,12 @@ class PlaidClient {
1923
2100
  */
1924
2101
  get: (id, asOf) =>
1925
2102
  this._request("GET", `/api/v1/projects/${id}`, {
2103
+ bypassBatch: true,
1926
2104
  queryParams: { "as-of": asOf },
1927
2105
  }),
1928
2106
  /**
1929
2107
  * List all documents in a project. Transparently follows pagination
1930
2108
  * cursors and returns the full flat array.
1931
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1932
2109
  *
1933
2110
  * Note: this endpoint does not support temporal (`as-of`) queries; the
1934
2111
  * server rejects `?as-of=` on the documents-list route with a 400.
@@ -1957,7 +2134,6 @@ class PlaidClient {
1957
2134
  * @param {string} id - The project ID
1958
2135
  * @param {object} [opts]
1959
2136
  * @param {number} [opts.pageSize] - Per-request page size
1960
- * 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.
1961
2137
  * @returns {AsyncGenerator<Array>}
1962
2138
  */
1963
2139
  iterDocuments: (id, { pageSize } = {}) =>
@@ -1991,7 +2167,6 @@ class PlaidClient {
1991
2167
  /**
1992
2168
  * List all projects accessible to user. Transparently follows pagination
1993
2169
  * cursors and returns the full flat array.
1994
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1995
2170
  * @param {string} [asOf] - Temporal query timestamp
1996
2171
  */
1997
2172
  list: (asOf) =>
@@ -2015,7 +2190,6 @@ class PlaidClient {
2015
2190
  * @param {object} [opts]
2016
2191
  * @param {number} [opts.pageSize] - Per-request page size
2017
2192
  * @param {string} [opts.asOf] - Temporal query timestamp
2018
- * 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.
2019
2193
  * @returns {AsyncGenerator<Array>}
2020
2194
  */
2021
2195
  iterPages: ({ pageSize, asOf } = {}) =>
@@ -2073,6 +2247,7 @@ class PlaidClient {
2073
2247
  */
2074
2248
  get: (textLayerId, asOf) =>
2075
2249
  this._request("GET", `/api/v1/text-layers/${textLayerId}`, {
2250
+ bypassBatch: true,
2076
2251
  queryParams: { "as-of": asOf },
2077
2252
  }),
2078
2253
  /**
@@ -2185,6 +2360,7 @@ class PlaidClient {
2185
2360
  */
2186
2361
  get: (id, asOf) =>
2187
2362
  this._request("GET", `/api/v1/vocab-items/${id}`, {
2363
+ bypassBatch: true,
2188
2364
  queryParams: { "as-of": asOf },
2189
2365
  }),
2190
2366
  /**
@@ -2265,6 +2441,7 @@ class PlaidClient {
2265
2441
  */
2266
2442
  get: (relationLayerId, asOf) =>
2267
2443
  this._request("GET", `/api/v1/relation-layers/${relationLayerId}`, {
2444
+ bypassBatch: true,
2268
2445
  queryParams: { "as-of": asOf },
2269
2446
  }),
2270
2447
  /**
@@ -2333,6 +2510,7 @@ class PlaidClient {
2333
2510
  */
2334
2511
  get: (tokenId, asOf) =>
2335
2512
  this._request("GET", `/api/v1/tokens/${tokenId}`, {
2513
+ bypassBatch: true,
2336
2514
  queryParams: { "as-of": asOf },
2337
2515
  }),
2338
2516
  /**
@@ -2370,6 +2548,16 @@ class PlaidClient {
2370
2548
  */
2371
2549
  bulkDelete: (body, auditMessage) =>
2372
2550
  this._request("DELETE", "/api/v1/tokens/bulk", { auditMessage, body }),
2551
+ /**
2552
+ * Update many tokens in a single operation: patch metadata.
2553
+ * @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.
2554
+ * @returns {Promise<{count: number}>} How many tokens were updated.
2555
+ */
2556
+ bulkUpdate: (body, auditMessage) =>
2557
+ this._request("PATCH", "/api/v1/tokens/bulk", {
2558
+ auditMessage,
2559
+ body,
2560
+ }),
2373
2561
  /**
2374
2562
  * Split a token at a Unicode code-point offset. The original token becomes the
2375
2563
  * left half (keeps its ID, spans, vocab-links); the new right token's ID is returned.
@@ -2473,7 +2661,8 @@ class PlaidClient {
2473
2661
  /**
2474
2662
  * Discover the services seen on a project (synchronous GET). Currently
2475
2663
  * connected services carry `online: true`; previously-seen offline ones
2476
- * carry `online: false` plus a `lastSeenAt` stamp.
2664
+ * carry `online: false` plus a `lastSeenAt` stamp. Goes over the wire
2665
+ * even while a batch is open on the client.
2477
2666
  * @param {string} projectId - The UUID of the project to query
2478
2667
  * @returns {Promise<Array>} Array of discovered service information
2479
2668
  */
@@ -2643,7 +2832,10 @@ class PlaidClient {
2643
2832
  * Get a logical-operation group (its label + creator).
2644
2833
  * @param {string} id - The group id
2645
2834
  */
2646
- get: (id) => this._request("GET", `/api/v1/operation-groups/${id}`),
2835
+ get: (id) =>
2836
+ this._request("GET", `/api/v1/operation-groups/${id}`, {
2837
+ bypassBatch: true,
2838
+ }),
2647
2839
  /**
2648
2840
  * Relabel a logical-operation group after the fact. Owner or admin only.
2649
2841
  * @param {string} id - The group id
@@ -2684,7 +2876,8 @@ class PlaidClient {
2684
2876
  * Read one comment.
2685
2877
  * @param {string} id - The comment id
2686
2878
  */
2687
- get: (id) => this._request("GET", `/api/v1/comments/${id}`),
2879
+ get: (id) =>
2880
+ this._request("GET", `/api/v1/comments/${id}`, { bypassBatch: true }),
2688
2881
  /**
2689
2882
  * Edit a comment's body. Only the comment's AUTHOR may do this - not
2690
2883
  * maintainers, not admins. Sets `edited` on the comment.
@@ -2702,7 +2895,6 @@ class PlaidClient {
2702
2895
  /**
2703
2896
  * List comments in a project, oldest first. Transparently follows
2704
2897
  * pagination cursors and returns the full flat array.
2705
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching - use listPage() for a single page in a batch.
2706
2898
  * @param {string} projectId - The project to read
2707
2899
  * @param {object} [filters] - Narrow the scope
2708
2900
  * @param {string} [filters.documentId] - Every comment anywhere in one document
@@ -2743,7 +2935,6 @@ class PlaidClient {
2743
2935
  }),
2744
2936
  /**
2745
2937
  * Async-iterate a project's comments page by page; yields each page's entries array.
2746
- * 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.
2747
2938
  * @param {string} projectId - The project to read
2748
2939
  * @param {object} [opts]
2749
2940
  * @param {number} [opts.pageSize] - Per-request page size
@@ -2778,6 +2969,7 @@ class PlaidClient {
2778
2969
  */
2779
2970
  counts: (projectId, { documentId, entityType, entityId } = {}) =>
2780
2971
  this._request("GET", `/api/v1/projects/${projectId}/comments/counts`, {
2972
+ bypassBatch: true,
2781
2973
  queryParams: {
2782
2974
  "document-id": documentId,
2783
2975
  "entity-type": entityType,
@@ -2789,7 +2981,6 @@ class PlaidClient {
2789
2981
  * List the comments on a vocabulary's entries, oldest first. Requires
2790
2982
  * read access to the vocabulary. Transparently follows pagination
2791
2983
  * cursors and returns the full flat array.
2792
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching - use listInVocabPage() for a single page in a batch.
2793
2984
  * @param {string} vocabId - The vocab layer to read
2794
2985
  * @param {object} [filters]
2795
2986
  * @param {string} [filters.entityId] - One entry's thread
@@ -2826,6 +3017,7 @@ class PlaidClient {
2826
3017
  "GET",
2827
3018
  `/api/v1/vocab-layers/${vocabId}/comments/counts`,
2828
3019
  {
3020
+ bypassBatch: true,
2829
3021
  queryParams: { "entity-id": entityId },
2830
3022
  skipResponseTransform: true,
2831
3023
  },
@@ -2872,6 +3064,12 @@ class PlaidClient {
2872
3064
  * write, so an operation that is never ended (crash, closed tab) is still
2873
3065
  * labeled in the log.
2874
3066
  *
3067
+ * "Every write" is the writes of project data. Reads never join, and neither
3068
+ * do the out-of-band signals shaped like a write (a document lock taken or
3069
+ * renewed, a stopped service request, a service reporting itself, an admin
3070
+ * control): none of them is audited, so there would be nothing under the
3071
+ * label.
3072
+ *
2875
3073
  * @param {string} message - Human label for the operation.
2876
3074
  * @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).
2877
3075
  * @returns {string} The operation's group id.
@@ -2947,12 +3145,15 @@ class PlaidClient {
2947
3145
  }
2948
3146
  }
2949
3147
 
2950
- /** Begin a batch of operations. Subsequent API calls will be queued. */
3148
+ /**
3149
+ * Begin a batch of operations. Subsequent WRITES are queued; reads and
3150
+ * out-of-band signals still go over the wire (see `batched`).
3151
+ */
2951
3152
  beginBatch() {
2952
3153
  this.isBatching = true;
2953
3154
  this.batchOperations = [];
2954
- // Strict mode stamps the expected document-version on the FIRST write of
2955
- // the batch only (see _request) — reset the marker per batch.
3155
+ // Strict mode stamps the expected document-version on the FIRST QUEUED
3156
+ // write of the batch only (see _request), so reset the marker per batch.
2956
3157
  this.batchVersionStamped = false;
2957
3158
  }
2958
3159
 
@@ -3085,6 +3286,18 @@ class PlaidClient {
3085
3286
  * parents created earlier in the same `fn`; any op's failure rolls the whole
3086
3287
  * batch back). Not nestable. Named `batched()` because `client.batch` is the
3087
3288
  * low-level batch resource.
3289
+ *
3290
+ * WRITES are what a batch queues, and batch mode is one flag on the whole
3291
+ * client, so it catches every write made while it is open, including writes
3292
+ * made by code that knows nothing about this batch. READS are never caught:
3293
+ * every read method goes over the wire while a batch is open and is answered
3294
+ * from the state the batch has not committed yet, so a read cannot come back
3295
+ * as a batch marker and cannot take a slot in the results array. Neither are
3296
+ * the calls that signal something out of band rather than write project data:
3297
+ * stopping a service request, a service reporting its progress or result,
3298
+ * taking and dropping a document lock, and the admin actions on the server
3299
+ * itself. See the note at the top of `http.js` for which column a new
3300
+ * endpoint belongs in.
3088
3301
  * @param {() => (void | Promise<void>)} fn
3089
3302
  * @returns {Promise<Array>}
3090
3303
  */
@@ -3276,6 +3489,14 @@ export {
3276
3489
  // request, and `critical()` holds that off around writes. See ./services.js
3277
3490
  // and the manual, "Stopping a request".
3278
3491
  export { ServiceCancelled, createCancelScope } from "./services.js";
3492
+ // Document locks: `client.documents.locked()` holds one for a block of work and
3493
+ // renews it while the block runs. DocumentLockLost is what a write throws once
3494
+ // a renewal has failed. See ./documentLock.js.
3495
+ export {
3496
+ DOCUMENT_LOCK_TTL_MS,
3497
+ DocumentLock,
3498
+ DocumentLockLost,
3499
+ } from "./documentLock.js";
3279
3500
  // Provenance: the cross-app convention for who made an annotation (flat
3280
3501
  // prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
3281
3502
  // a machine; 'contributed' = a person whose work is reviewed), plus the