@larc-iu/plaid-client 0.2.0-alpha.13 → 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
  /**
@@ -477,7 +483,7 @@ class PlaidClient {
477
483
  }),
478
484
  /**
479
485
  * 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.
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.
481
487
  * @returns {Promise<{count: number}>} How many relations were updated.
482
488
  */
483
489
  bulkUpdate: (body, auditMessage) =>
@@ -526,6 +532,7 @@ class PlaidClient {
526
532
  */
527
533
  get: (spanLayerId, asOf) =>
528
534
  this._request("GET", `/api/v1/span-layers/${spanLayerId}`, {
535
+ bypassBatch: true,
529
536
  queryParams: { "as-of": asOf },
530
537
  }),
531
538
  /**
@@ -604,6 +611,7 @@ class PlaidClient {
604
611
  */
605
612
  get: (spanId, asOf) =>
606
613
  this._request("GET", `/api/v1/spans/${spanId}`, {
614
+ bypassBatch: true,
607
615
  queryParams: { "as-of": asOf },
608
616
  }),
609
617
  /**
@@ -637,7 +645,7 @@ class PlaidClient {
637
645
  this._request("DELETE", "/api/v1/spans/bulk", { auditMessage, body }),
638
646
  /**
639
647
  * 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.
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.
641
649
  * @returns {Promise<{count: number}>} How many spans were updated.
642
650
  */
643
651
  bulkUpdate: (body, auditMessage) =>
@@ -785,6 +793,7 @@ class PlaidClient {
785
793
  */
786
794
  get: (textId, asOf) =>
787
795
  this._request("GET", `/api/v1/texts/${textId}`, {
796
+ bypassBatch: true,
788
797
  queryParams: { "as-of": asOf },
789
798
  }),
790
799
  /**
@@ -816,7 +825,6 @@ class PlaidClient {
816
825
  /**
817
826
  * List (or search) users. Transparently follows pagination cursors and
818
827
  * 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
828
  * @param {object} [opts]
821
829
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
822
830
  * @param {string} [opts.asOf] - Temporal query timestamp
@@ -844,7 +852,6 @@ class PlaidClient {
844
852
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
845
853
  * @param {number} [opts.pageSize] - Per-request page size
846
854
  * @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
855
  * @returns {AsyncGenerator<Array>}
849
856
  */
850
857
  iterPages: ({ q, pageSize, asOf } = {}) =>
@@ -874,7 +881,6 @@ class PlaidClient {
874
881
  /**
875
882
  * Get audit log for a user's actions. Transparently follows pagination
876
883
  * 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
884
  * @param {string} userId - The user ID
879
885
  * @param {string} [startTime] - Start of time range
880
886
  * @param {string} [endTime] - End of time range
@@ -925,6 +931,7 @@ class PlaidClient {
925
931
  */
926
932
  get: (id, asOf) =>
927
933
  this._request("GET", `/api/v1/users/${id}`, {
934
+ bypassBatch: true,
928
935
  queryParams: { "as-of": asOf },
929
936
  }),
930
937
  /**
@@ -994,7 +1001,7 @@ class PlaidClient {
994
1001
  */
995
1002
  getAvatar: (id) =>
996
1003
  this._request("GET", `/api/v1/users/${id}/avatar`, {
997
- noBatch: true,
1004
+ bypassBatch: true,
998
1005
  binaryResponse: true,
999
1006
  }),
1000
1007
  /**
@@ -1019,32 +1026,86 @@ class PlaidClient {
1019
1026
  * Remove a profile picture. Your own, or anyone's if you are an admin.
1020
1027
  * @param {string} id - The user ID
1021
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.
1022
1032
  deleteAvatar: (id, auditMessage) =>
1023
1033
  this._request("DELETE", `/api/v1/users/${id}/avatar`, {
1024
1034
  auditMessage,
1025
- noBatch: true,
1026
1035
  }),
1027
1036
  };
1028
1037
 
1029
1038
  this.userData = {
1030
1039
  /**
1031
1040
  * List a user's private data entries ({key, updatedAt}, plus value when
1032
- * 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.
1033
1043
  *
1034
1044
  * Narrow with `prefix` (the literal head of a key) and/or `pattern`, a
1035
1045
  * GLOB over the whole key (`*` any run, `?` one character) — the way to
1036
1046
  * ask for a key convention identified by a segment in the middle, e.g.
1037
1047
  * `igt:assistant:*:meta:*` for every conversation's sidebar entry across
1038
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.
1039
1095
  * @param {string} userId
1040
1096
  * @param {object} [opts]
1041
1097
  * @param {string} [opts.prefix] - Only keys starting with this prefix
1042
1098
  * @param {string} [opts.pattern] - Only keys matching this GLOB
1043
1099
  * @param {boolean} [opts.includeValues] - Also return each entry's value
1100
+ * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1044
1101
  */
1045
- list: (userId, { prefix, pattern, includeValues } = {}) =>
1046
- this._request("GET", `/api/v1/users/${userId}/data`, {
1047
- queryParams: { prefix, pattern, "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 },
1048
1109
  }),
1049
1110
  /**
1050
1111
  * Read one private data entry ({key, updatedAt, value}); 404 if absent.
@@ -1052,9 +1113,11 @@ class PlaidClient {
1052
1113
  * @param {string} key
1053
1114
  */
1054
1115
  get: (userId, key) =>
1116
+ // bypassBatch for the same reason as list() above.
1055
1117
  this._request(
1056
1118
  "GET",
1057
1119
  `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1120
+ { bypassBatch: true },
1058
1121
  ),
1059
1122
  /**
1060
1123
  * Create or replace one private data entry. `value` is any JSON (up to
@@ -1098,7 +1161,6 @@ class PlaidClient {
1098
1161
  * List a user's named API tokens. Never includes the signed token
1099
1162
  * string itself — that is only returned once, by create().
1100
1163
  * 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
1164
  * @param {string} userId - The user ID who owns the tokens
1103
1165
  */
1104
1166
  list: (userId) => listAll(this, `/api/v1/users/${userId}/tokens`),
@@ -1118,7 +1180,6 @@ class PlaidClient {
1118
1180
  * @param {string} userId - The user ID who owns the tokens
1119
1181
  * @param {object} [opts]
1120
1182
  * @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
1183
  * @returns {AsyncGenerator<Array>}
1123
1184
  */
1124
1185
  iterPages: (userId, { pageSize } = {}) =>
@@ -1154,7 +1215,6 @@ class PlaidClient {
1154
1215
  * maintainer or admin on it. Never includes invite codes — the code is
1155
1216
  * returned once, by create(), and is not recoverable afterward.
1156
1217
  * 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
1218
  * @param {object} [opts]
1159
1219
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1160
1220
  * @param {boolean} [opts.all] - List every invite on the server (admin only)
@@ -1184,7 +1244,6 @@ class PlaidClient {
1184
1244
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1185
1245
  * @param {boolean} [opts.all] - List every invite on the server (admin only)
1186
1246
  * @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
1247
  * @returns {AsyncGenerator<Array>}
1189
1248
  */
1190
1249
  iterPages: ({ projectId, all, pageSize } = {}) =>
@@ -1259,35 +1318,48 @@ class PlaidClient {
1259
1318
  * walks the media directory, so open it, do not poll it. Admin only.
1260
1319
  * @returns {Promise<{version: string, jvm: object, database: object, media: object, backup: object, settings: object}>}
1261
1320
  */
1262
- server: () => this._request("GET", "/api/v1/admin/server"),
1321
+ server: () =>
1322
+ this._request("GET", "/api/v1/admin/server", { bypassBatch: true }),
1263
1323
  /**
1264
1324
  * Take a database backup right now, outside the nightly schedule.
1265
1325
  * Resolves to the backup block with `ok` reporting whether the snapshot
1266
1326
  * succeeded. Uses VACUUM INTO, which only reads, so it is safe while
1267
1327
  * people are working. Admin only.
1268
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.
1269
1334
  */
1270
- backup: () => this._request("POST", "/api/v1/admin/backup"),
1335
+ backup: () =>
1336
+ this._request("POST", "/api/v1/admin/backup", { bypassBatch: true }),
1271
1337
  /**
1272
1338
  * Documents currently held by an editing lock, with who holds each and
1273
1339
  * when it expires on its own. Admin only.
1274
1340
  * @returns {Promise<{entries: Array<{documentId: string, userId: string, expiresAt: number}>}>}
1275
1341
  */
1276
- locks: () => this._request("GET", "/api/v1/admin/locks"),
1342
+ locks: () =>
1343
+ this._request("GET", "/api/v1/admin/locks", { bypassBatch: true }),
1277
1344
  /**
1278
1345
  * Drop the lock on a document whoever holds it. Idempotent. For a client
1279
1346
  * that went away without releasing one. Admin only.
1280
1347
  * @param {string} documentId - The document ID
1281
1348
  */
1282
1349
  releaseLock: (documentId) =>
1283
- this._request("DELETE", `/api/v1/admin/locks/${documentId}`),
1350
+ this._request("DELETE", `/api/v1/admin/locks/${documentId}`, {
1351
+ bypassBatch: true,
1352
+ }),
1284
1353
  /**
1285
1354
  * Live login and invite rate-limit buckets: the address, the account
1286
1355
  * where there is one, failures inside the window, the limit, and whether
1287
1356
  * it is currently blocking. Admin only.
1288
1357
  * @returns {Promise<{windowMs: number, logins: Array, ips: Array, invites: Array}>}
1289
1358
  */
1290
- rateLimits: () => this._request("GET", "/api/v1/admin/rate-limits"),
1359
+ rateLimits: () =>
1360
+ this._request("GET", "/api/v1/admin/rate-limits", {
1361
+ bypassBatch: true,
1362
+ }),
1291
1363
  /**
1292
1364
  * Forget recorded rate-limit failures. With `ip`, clears that address,
1293
1365
  * narrowed to one account with `userId`. With neither, clears every
@@ -1298,18 +1370,45 @@ class PlaidClient {
1298
1370
  */
1299
1371
  clearRateLimits: ({ ip, userId } = {}) =>
1300
1372
  this._request("DELETE", "/api/v1/admin/rate-limits", {
1373
+ bypassBatch: true,
1301
1374
  queryParams: { ip, "user-id": userId },
1302
1375
  }),
1303
1376
  /**
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.
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.
1307
1405
  * @param {object} [opts]
1308
1406
  * @param {number} [opts.lines] - How many lines (default 200, max 2000)
1309
1407
  * @returns {Promise<{file: (string|null), lines: string[], error?: string}>}
1310
1408
  */
1311
- logs: ({ lines } = {}) =>
1312
- this._request("GET", "/api/v1/admin/logs", {
1409
+ logFile: ({ lines } = {}) =>
1410
+ this._request("GET", "/api/v1/admin/logs/file", {
1411
+ bypassBatch: true,
1313
1412
  queryParams: { lines },
1314
1413
  }),
1315
1414
  /**
@@ -1323,7 +1422,6 @@ class PlaidClient {
1323
1422
  * ask for a key convention identified by a segment in the middle, e.g.
1324
1423
  * `igt:assistant:*:meta:*`. Values come only with `includeValues`, and
1325
1424
  * 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
1425
  * @param {object} [opts]
1328
1426
  * @param {string} [opts.prefix] - Only keys starting with this
1329
1427
  * @param {string} [opts.pattern] - Only keys matching this GLOB
@@ -1367,7 +1465,6 @@ class PlaidClient {
1367
1465
  * and op-type filter as the per-project read, with the entity scope
1368
1466
  * dropped. Admin only. Transparently follows pagination cursors and
1369
1467
  * 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
1468
  * @param {object} [opts]
1372
1469
  * @param {string} [opts.startTime] - Only operations at or after this instant
1373
1470
  * @param {string} [opts.endTime] - Only operations at or before this instant
@@ -1405,7 +1502,6 @@ class PlaidClient {
1405
1502
  }),
1406
1503
  /**
1407
1504
  * 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
1505
  * @returns {AsyncGenerator<Array>}
1410
1506
  */
1411
1507
  iterPages: ({ startTime, endTime, opTypes, pageSize } = {}) =>
@@ -1440,6 +1536,7 @@ class PlaidClient {
1440
1536
  ? `/api/v1/projects/${projectId}/audit/tally`
1441
1537
  : "/api/v1/audit/tally";
1442
1538
  const result = await this._request("GET", path, {
1539
+ bypassBatch: true,
1443
1540
  queryParams: {
1444
1541
  "start-time": startTime,
1445
1542
  "end-time": endTime,
@@ -1522,6 +1619,7 @@ class PlaidClient {
1522
1619
  */
1523
1620
  get: (tokenLayerId, asOf) =>
1524
1621
  this._request("GET", `/api/v1/token-layers/${tokenLayerId}`, {
1622
+ bypassBatch: true,
1525
1623
  queryParams: { "as-of": asOf },
1526
1624
  }),
1527
1625
  /**
@@ -1552,24 +1650,72 @@ class PlaidClient {
1552
1650
  */
1553
1651
  checkLock: (documentId, asOf) =>
1554
1652
  this._request("GET", `/api/v1/documents/${documentId}/lock`, {
1653
+ bypassBatch: true,
1555
1654
  queryParams: { "as-of": asOf },
1556
1655
  }),
1557
1656
  /**
1558
- * 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.
1559
1664
  * @param {string} documentId - The document ID
1560
1665
  */
1561
1666
  acquireLock: (documentId, auditMessage) =>
1562
1667
  this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1563
1668
  auditMessage,
1669
+ bypassBatch: true,
1564
1670
  }),
1565
1671
  /**
1566
- * 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.
1567
1675
  * @param {string} documentId - The document ID
1568
1676
  */
1569
1677
  releaseLock: (documentId, auditMessage) =>
1570
1678
  this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
1571
1679
  auditMessage,
1680
+ bypassBatch: true,
1572
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),
1573
1719
  /**
1574
1720
  * Get the media file for a document. Media is not versioned, so there is
1575
1721
  * no as-of form: the route refuses the parameter. Prefer the document's
@@ -1578,7 +1724,7 @@ class PlaidClient {
1578
1724
  */
1579
1725
  getMedia: (documentId) =>
1580
1726
  this._request("GET", `/api/v1/documents/${documentId}/media`, {
1581
- noBatch: true,
1727
+ bypassBatch: true,
1582
1728
  binaryResponse: true,
1583
1729
  }),
1584
1730
  /**
@@ -1603,10 +1749,14 @@ class PlaidClient {
1603
1749
  * Delete media file for a document
1604
1750
  * @param {string} documentId - The document ID
1605
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.
1606
1757
  deleteMedia: (documentId, auditMessage) =>
1607
1758
  this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
1608
1759
  auditMessage,
1609
- noBatch: true,
1610
1760
  }),
1611
1761
  /**
1612
1762
  * Replace all metadata for a document.
@@ -1642,7 +1792,6 @@ class PlaidClient {
1642
1792
  /**
1643
1793
  * Get audit log for a document. Transparently follows pagination cursors
1644
1794
  * 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
1795
  * @param {string} documentId - The document ID
1647
1796
  * @param {string} [startTime] - Start of time range
1648
1797
  * @param {string} [endTime] - End of time range
@@ -1725,6 +1874,7 @@ class PlaidClient {
1725
1874
  */
1726
1875
  get: (documentId, includeBody, asOf, layers) =>
1727
1876
  this._request("GET", `/api/v1/documents/${documentId}`, {
1877
+ bypassBatch: true,
1728
1878
  queryParams: {
1729
1879
  "include-body": includeBody,
1730
1880
  "as-of": asOf,
@@ -1866,7 +2016,6 @@ class PlaidClient {
1866
2016
  /**
1867
2017
  * Get audit log for a project. Transparently follows pagination cursors
1868
2018
  * 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
2019
  * @param {string} projectId - The project ID
1871
2020
  * @param {string} [startTime] - Start of time range
1872
2021
  * @param {string} [endTime] - End of time range
@@ -1922,6 +2071,7 @@ class PlaidClient {
1922
2071
  */
1923
2072
  myLastEdits: (projectId) =>
1924
2073
  this._request("GET", `/api/v1/projects/${projectId}/audit/last-edits`, {
2074
+ bypassBatch: true,
1925
2075
  skipResponseTransform: true,
1926
2076
  }),
1927
2077
  /**
@@ -1950,12 +2100,12 @@ class PlaidClient {
1950
2100
  */
1951
2101
  get: (id, asOf) =>
1952
2102
  this._request("GET", `/api/v1/projects/${id}`, {
2103
+ bypassBatch: true,
1953
2104
  queryParams: { "as-of": asOf },
1954
2105
  }),
1955
2106
  /**
1956
2107
  * List all documents in a project. Transparently follows pagination
1957
2108
  * 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
2109
  *
1960
2110
  * Note: this endpoint does not support temporal (`as-of`) queries; the
1961
2111
  * server rejects `?as-of=` on the documents-list route with a 400.
@@ -1984,7 +2134,6 @@ class PlaidClient {
1984
2134
  * @param {string} id - The project ID
1985
2135
  * @param {object} [opts]
1986
2136
  * @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
2137
  * @returns {AsyncGenerator<Array>}
1989
2138
  */
1990
2139
  iterDocuments: (id, { pageSize } = {}) =>
@@ -2018,7 +2167,6 @@ class PlaidClient {
2018
2167
  /**
2019
2168
  * List all projects accessible to user. Transparently follows pagination
2020
2169
  * 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
2170
  * @param {string} [asOf] - Temporal query timestamp
2023
2171
  */
2024
2172
  list: (asOf) =>
@@ -2042,7 +2190,6 @@ class PlaidClient {
2042
2190
  * @param {object} [opts]
2043
2191
  * @param {number} [opts.pageSize] - Per-request page size
2044
2192
  * @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
2193
  * @returns {AsyncGenerator<Array>}
2047
2194
  */
2048
2195
  iterPages: ({ pageSize, asOf } = {}) =>
@@ -2100,6 +2247,7 @@ class PlaidClient {
2100
2247
  */
2101
2248
  get: (textLayerId, asOf) =>
2102
2249
  this._request("GET", `/api/v1/text-layers/${textLayerId}`, {
2250
+ bypassBatch: true,
2103
2251
  queryParams: { "as-of": asOf },
2104
2252
  }),
2105
2253
  /**
@@ -2212,6 +2360,7 @@ class PlaidClient {
2212
2360
  */
2213
2361
  get: (id, asOf) =>
2214
2362
  this._request("GET", `/api/v1/vocab-items/${id}`, {
2363
+ bypassBatch: true,
2215
2364
  queryParams: { "as-of": asOf },
2216
2365
  }),
2217
2366
  /**
@@ -2292,6 +2441,7 @@ class PlaidClient {
2292
2441
  */
2293
2442
  get: (relationLayerId, asOf) =>
2294
2443
  this._request("GET", `/api/v1/relation-layers/${relationLayerId}`, {
2444
+ bypassBatch: true,
2295
2445
  queryParams: { "as-of": asOf },
2296
2446
  }),
2297
2447
  /**
@@ -2360,6 +2510,7 @@ class PlaidClient {
2360
2510
  */
2361
2511
  get: (tokenId, asOf) =>
2362
2512
  this._request("GET", `/api/v1/tokens/${tokenId}`, {
2513
+ bypassBatch: true,
2363
2514
  queryParams: { "as-of": asOf },
2364
2515
  }),
2365
2516
  /**
@@ -2399,7 +2550,7 @@ class PlaidClient {
2399
2550
  this._request("DELETE", "/api/v1/tokens/bulk", { auditMessage, body }),
2400
2551
  /**
2401
2552
  * 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.
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.
2403
2554
  * @returns {Promise<{count: number}>} How many tokens were updated.
2404
2555
  */
2405
2556
  bulkUpdate: (body, auditMessage) =>
@@ -2510,7 +2661,8 @@ class PlaidClient {
2510
2661
  /**
2511
2662
  * Discover the services seen on a project (synchronous GET). Currently
2512
2663
  * connected services carry `online: true`; previously-seen offline ones
2513
- * 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.
2514
2666
  * @param {string} projectId - The UUID of the project to query
2515
2667
  * @returns {Promise<Array>} Array of discovered service information
2516
2668
  */
@@ -2680,7 +2832,10 @@ class PlaidClient {
2680
2832
  * Get a logical-operation group (its label + creator).
2681
2833
  * @param {string} id - The group id
2682
2834
  */
2683
- 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
+ }),
2684
2839
  /**
2685
2840
  * Relabel a logical-operation group after the fact. Owner or admin only.
2686
2841
  * @param {string} id - The group id
@@ -2721,7 +2876,8 @@ class PlaidClient {
2721
2876
  * Read one comment.
2722
2877
  * @param {string} id - The comment id
2723
2878
  */
2724
- get: (id) => this._request("GET", `/api/v1/comments/${id}`),
2879
+ get: (id) =>
2880
+ this._request("GET", `/api/v1/comments/${id}`, { bypassBatch: true }),
2725
2881
  /**
2726
2882
  * Edit a comment's body. Only the comment's AUTHOR may do this - not
2727
2883
  * maintainers, not admins. Sets `edited` on the comment.
@@ -2739,7 +2895,6 @@ class PlaidClient {
2739
2895
  /**
2740
2896
  * List comments in a project, oldest first. Transparently follows
2741
2897
  * 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
2898
  * @param {string} projectId - The project to read
2744
2899
  * @param {object} [filters] - Narrow the scope
2745
2900
  * @param {string} [filters.documentId] - Every comment anywhere in one document
@@ -2780,7 +2935,6 @@ class PlaidClient {
2780
2935
  }),
2781
2936
  /**
2782
2937
  * 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
2938
  * @param {string} projectId - The project to read
2785
2939
  * @param {object} [opts]
2786
2940
  * @param {number} [opts.pageSize] - Per-request page size
@@ -2815,6 +2969,7 @@ class PlaidClient {
2815
2969
  */
2816
2970
  counts: (projectId, { documentId, entityType, entityId } = {}) =>
2817
2971
  this._request("GET", `/api/v1/projects/${projectId}/comments/counts`, {
2972
+ bypassBatch: true,
2818
2973
  queryParams: {
2819
2974
  "document-id": documentId,
2820
2975
  "entity-type": entityType,
@@ -2826,7 +2981,6 @@ class PlaidClient {
2826
2981
  * List the comments on a vocabulary's entries, oldest first. Requires
2827
2982
  * read access to the vocabulary. Transparently follows pagination
2828
2983
  * 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
2984
  * @param {string} vocabId - The vocab layer to read
2831
2985
  * @param {object} [filters]
2832
2986
  * @param {string} [filters.entityId] - One entry's thread
@@ -2863,6 +3017,7 @@ class PlaidClient {
2863
3017
  "GET",
2864
3018
  `/api/v1/vocab-layers/${vocabId}/comments/counts`,
2865
3019
  {
3020
+ bypassBatch: true,
2866
3021
  queryParams: { "entity-id": entityId },
2867
3022
  skipResponseTransform: true,
2868
3023
  },
@@ -2909,6 +3064,12 @@ class PlaidClient {
2909
3064
  * write, so an operation that is never ended (crash, closed tab) is still
2910
3065
  * labeled in the log.
2911
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
+ *
2912
3073
  * @param {string} message - Human label for the operation.
2913
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).
2914
3075
  * @returns {string} The operation's group id.
@@ -2984,12 +3145,15 @@ class PlaidClient {
2984
3145
  }
2985
3146
  }
2986
3147
 
2987
- /** 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
+ */
2988
3152
  beginBatch() {
2989
3153
  this.isBatching = true;
2990
3154
  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.
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.
2993
3157
  this.batchVersionStamped = false;
2994
3158
  }
2995
3159
 
@@ -3122,6 +3286,18 @@ class PlaidClient {
3122
3286
  * parents created earlier in the same `fn`; any op's failure rolls the whole
3123
3287
  * batch back). Not nestable. Named `batched()` because `client.batch` is the
3124
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.
3125
3301
  * @param {() => (void | Promise<void>)} fn
3126
3302
  * @returns {Promise<Array>}
3127
3303
  */
@@ -3313,6 +3489,14 @@ export {
3313
3489
  // request, and `critical()` holds that off around writes. See ./services.js
3314
3490
  // and the manual, "Stopping a request".
3315
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";
3316
3500
  // Provenance: the cross-app convention for who made an annotation (flat
3317
3501
  // prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
3318
3502
  // a machine; 'contributed' = a person whose work is reviewed), plus the