@larc-iu/plaid-client 0.2.0-alpha.14 → 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,
@@ -117,6 +118,55 @@ async function anonymousGet(baseUrl, path, options = {}) {
117
118
  */
118
119
  const MAX_BATCH_OPS = 1000;
119
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
+
120
170
  class PlaidClient {
121
171
  /**
122
172
  * Create a new PlaidClient instance
@@ -142,8 +192,6 @@ class PlaidClient {
142
192
  : options.timeout !== undefined
143
193
  ? options.timeout
144
194
  : DEFAULT_BATCH_TIMEOUT_MS;
145
- this.isBatching = false;
146
- this.batchOperations = [];
147
195
  this.documentVersions = {};
148
196
  this.strictModeDocumentId = null;
149
197
  // Set to a DocumentLockLost while a `documents.locked()` block's keep-alive
@@ -161,8 +209,16 @@ class PlaidClient {
161
209
  this.onAuthError = options.onAuthError || null;
162
210
  this._authErrorFired = false;
163
211
 
164
- // --- API Bundles ---
212
+ this._installResources();
213
+ }
165
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() {
166
222
  this.vocabLinks = {
167
223
  /**
168
224
  * Create a new vocab link between tokens and a vocab item.
@@ -234,7 +290,6 @@ class PlaidClient {
234
290
  */
235
291
  get: (id, asOf) =>
236
292
  this._request("GET", `/api/v1/vocab-links/${id}`, {
237
- bypassBatch: true,
238
293
  queryParams: { "as-of": asOf },
239
294
  }),
240
295
  /**
@@ -254,7 +309,6 @@ class PlaidClient {
254
309
  */
255
310
  get: (id, includeItems, asOf) =>
256
311
  this._request("GET", `/api/v1/vocab-layers/${id}`, {
257
- bypassBatch: true,
258
312
  queryParams: { "include-items": includeItems, "as-of": asOf },
259
313
  }),
260
314
  /**
@@ -413,7 +467,6 @@ class PlaidClient {
413
467
  */
414
468
  get: (relationId, asOf) =>
415
469
  this._request("GET", `/api/v1/relations/${relationId}`, {
416
- bypassBatch: true,
417
470
  queryParams: { "as-of": asOf },
418
471
  }),
419
472
  /**
@@ -532,7 +585,6 @@ class PlaidClient {
532
585
  */
533
586
  get: (spanLayerId, asOf) =>
534
587
  this._request("GET", `/api/v1/span-layers/${spanLayerId}`, {
535
- bypassBatch: true,
536
588
  queryParams: { "as-of": asOf },
537
589
  }),
538
590
  /**
@@ -611,7 +663,6 @@ class PlaidClient {
611
663
  */
612
664
  get: (spanId, asOf) =>
613
665
  this._request("GET", `/api/v1/spans/${spanId}`, {
614
- bypassBatch: true,
615
666
  queryParams: { "as-of": asOf },
616
667
  }),
617
668
  /**
@@ -698,11 +749,7 @@ class PlaidClient {
698
749
  */
699
750
  info: () => {
700
751
  if (!infoPromise) {
701
- // bypassBatch: a read belongs to whoever asked for it, not to
702
- // whatever batch happens to be open on this shared client.
703
- infoPromise = this._request("GET", "/api/v1/info", {
704
- bypassBatch: true,
705
- }).catch((err) => {
752
+ infoPromise = this._request("GET", "/api/v1/info").catch((err) => {
706
753
  // A failure must not be cached: a client that starts before the
707
754
  // server is up would never see the limits at all.
708
755
  infoPromise = null;
@@ -722,20 +769,6 @@ class PlaidClient {
722
769
  health: () => PlaidClient.health(this.baseUrl, { timeout: this.timeout }),
723
770
  };
724
771
 
725
- this.batch = {
726
- /**
727
- * Execute multiple API operations atomically. If any operation fails, all
728
- * changes are rolled back.
729
- * @param {Array} body - The request body
730
- */
731
- submit: (body, auditMessage) =>
732
- this._request("POST", "/api/v1/batch", {
733
- auditMessage,
734
- body,
735
- noBatch: true,
736
- }),
737
- };
738
-
739
772
  this.texts = {
740
773
  /**
741
774
  * Replace all metadata for a text.
@@ -793,7 +826,6 @@ class PlaidClient {
793
826
  */
794
827
  get: (textId, asOf) =>
795
828
  this._request("GET", `/api/v1/texts/${textId}`, {
796
- bypassBatch: true,
797
829
  queryParams: { "as-of": asOf },
798
830
  }),
799
831
  /**
@@ -931,7 +963,6 @@ class PlaidClient {
931
963
  */
932
964
  get: (id, asOf) =>
933
965
  this._request("GET", `/api/v1/users/${id}`, {
934
- bypassBatch: true,
935
966
  queryParams: { "as-of": asOf },
936
967
  }),
937
968
  /**
@@ -1001,7 +1032,6 @@ class PlaidClient {
1001
1032
  */
1002
1033
  getAvatar: (id) =>
1003
1034
  this._request("GET", `/api/v1/users/${id}/avatar`, {
1004
- bypassBatch: true,
1005
1035
  binaryResponse: true,
1006
1036
  }),
1007
1037
  /**
@@ -1059,13 +1089,6 @@ class PlaidClient {
1059
1089
  * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1060
1090
  */
1061
1091
  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
1092
  listAll(this, `/api/v1/users/${userId}/data`, {
1070
1093
  pageSize,
1071
1094
  query: { prefix, pattern, "include-values": includeValues },
@@ -1113,11 +1136,9 @@ class PlaidClient {
1113
1136
  * @param {string} key
1114
1137
  */
1115
1138
  get: (userId, key) =>
1116
- // bypassBatch for the same reason as list() above.
1117
1139
  this._request(
1118
1140
  "GET",
1119
1141
  `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1120
- { bypassBatch: true },
1121
1142
  ),
1122
1143
  /**
1123
1144
  * Create or replace one private data entry. `value` is any JSON (up to
@@ -1318,8 +1339,7 @@ class PlaidClient {
1318
1339
  * walks the media directory, so open it, do not poll it. Admin only.
1319
1340
  * @returns {Promise<{version: string, jvm: object, database: object, media: object, backup: object, settings: object}>}
1320
1341
  */
1321
- server: () =>
1322
- this._request("GET", "/api/v1/admin/server", { bypassBatch: true }),
1342
+ server: () => this._request("GET", "/api/v1/admin/server"),
1323
1343
  /**
1324
1344
  * Take a database backup right now, outside the nightly schedule.
1325
1345
  * Resolves to the backup block with `ok` reporting whether the snapshot
@@ -1327,20 +1347,17 @@ class PlaidClient {
1327
1347
  * people are working. Admin only.
1328
1348
  * @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
1329
1349
  *
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.
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).
1334
1352
  */
1335
1353
  backup: () =>
1336
- this._request("POST", "/api/v1/admin/backup", { bypassBatch: true }),
1354
+ this._request("POST", "/api/v1/admin/backup", { outOfBand: true }),
1337
1355
  /**
1338
1356
  * Documents currently held by an editing lock, with who holds each and
1339
1357
  * when it expires on its own. Admin only.
1340
1358
  * @returns {Promise<{entries: Array<{documentId: string, userId: string, expiresAt: number}>}>}
1341
1359
  */
1342
- locks: () =>
1343
- this._request("GET", "/api/v1/admin/locks", { bypassBatch: true }),
1360
+ locks: () => this._request("GET", "/api/v1/admin/locks"),
1344
1361
  /**
1345
1362
  * Drop the lock on a document whoever holds it. Idempotent. For a client
1346
1363
  * that went away without releasing one. Admin only.
@@ -1348,7 +1365,7 @@ class PlaidClient {
1348
1365
  */
1349
1366
  releaseLock: (documentId) =>
1350
1367
  this._request("DELETE", `/api/v1/admin/locks/${documentId}`, {
1351
- bypassBatch: true,
1368
+ outOfBand: true,
1352
1369
  }),
1353
1370
  /**
1354
1371
  * Live login and invite rate-limit buckets: the address, the account
@@ -1356,10 +1373,7 @@ class PlaidClient {
1356
1373
  * it is currently blocking. Admin only.
1357
1374
  * @returns {Promise<{windowMs: number, logins: Array, ips: Array, invites: Array}>}
1358
1375
  */
1359
- rateLimits: () =>
1360
- this._request("GET", "/api/v1/admin/rate-limits", {
1361
- bypassBatch: true,
1362
- }),
1376
+ rateLimits: () => this._request("GET", "/api/v1/admin/rate-limits"),
1363
1377
  /**
1364
1378
  * Forget recorded rate-limit failures. With `ip`, clears that address,
1365
1379
  * narrowed to one account with `userId`. With neither, clears every
@@ -1370,7 +1384,7 @@ class PlaidClient {
1370
1384
  */
1371
1385
  clearRateLimits: ({ ip, userId } = {}) =>
1372
1386
  this._request("DELETE", "/api/v1/admin/rate-limits", {
1373
- bypassBatch: true,
1387
+ outOfBand: true,
1374
1388
  queryParams: { ip, "user-id": userId },
1375
1389
  }),
1376
1390
  /**
@@ -1394,7 +1408,6 @@ class PlaidClient {
1394
1408
  */
1395
1409
  logs: ({ limit, q, level, status, user, method } = {}) =>
1396
1410
  this._request("GET", "/api/v1/admin/logs", {
1397
- bypassBatch: true,
1398
1411
  queryParams: { limit, q, level, status, user, method },
1399
1412
  }),
1400
1413
  /**
@@ -1408,7 +1421,6 @@ class PlaidClient {
1408
1421
  */
1409
1422
  logFile: ({ lines } = {}) =>
1410
1423
  this._request("GET", "/api/v1/admin/logs/file", {
1411
- bypassBatch: true,
1412
1424
  queryParams: { lines },
1413
1425
  }),
1414
1426
  /**
@@ -1536,7 +1548,6 @@ class PlaidClient {
1536
1548
  ? `/api/v1/projects/${projectId}/audit/tally`
1537
1549
  : "/api/v1/audit/tally";
1538
1550
  const result = await this._request("GET", path, {
1539
- bypassBatch: true,
1540
1551
  queryParams: {
1541
1552
  "start-time": startTime,
1542
1553
  "end-time": endTime,
@@ -1619,7 +1630,6 @@ class PlaidClient {
1619
1630
  */
1620
1631
  get: (tokenLayerId, asOf) =>
1621
1632
  this._request("GET", `/api/v1/token-layers/${tokenLayerId}`, {
1622
- bypassBatch: true,
1623
1633
  queryParams: { "as-of": asOf },
1624
1634
  }),
1625
1635
  /**
@@ -1650,34 +1660,33 @@ class PlaidClient {
1650
1660
  */
1651
1661
  checkLock: (documentId, asOf) =>
1652
1662
  this._request("GET", `/api/v1/documents/${documentId}/lock`, {
1653
- bypassBatch: true,
1654
1663
  queryParams: { "as-of": asOf },
1655
1664
  }),
1656
1665
  /**
1657
1666
  * Acquire or refresh a document lock.
1658
1667
  *
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.
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.
1664
1673
  * @param {string} documentId - The document ID
1665
1674
  */
1666
1675
  acquireLock: (documentId, auditMessage) =>
1667
1676
  this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1668
1677
  auditMessage,
1669
- bypassBatch: true,
1678
+ outOfBand: true,
1670
1679
  }),
1671
1680
  /**
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.
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.
1675
1684
  * @param {string} documentId - The document ID
1676
1685
  */
1677
1686
  releaseLock: (documentId, auditMessage) =>
1678
1687
  this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
1679
1688
  auditMessage,
1680
- bypassBatch: true,
1689
+ outOfBand: true,
1681
1690
  }),
1682
1691
  /**
1683
1692
  * Hold this document's server-enforced lock for the length of `fn`,
@@ -1724,7 +1733,6 @@ class PlaidClient {
1724
1733
  */
1725
1734
  getMedia: (documentId) =>
1726
1735
  this._request("GET", `/api/v1/documents/${documentId}/media`, {
1727
- bypassBatch: true,
1728
1736
  binaryResponse: true,
1729
1737
  }),
1730
1738
  /**
@@ -1749,8 +1757,8 @@ class PlaidClient {
1749
1757
  * Delete media file for a document
1750
1758
  * @param {string} documentId - The document ID
1751
1759
  */
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
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
1754
1762
  // of the document's own data and queues like any other. Note that the
1755
1763
  // file removal happens outside the server's transaction, so a batch that
1756
1764
  // aborts after this op does not bring the file back.
@@ -1874,7 +1882,6 @@ class PlaidClient {
1874
1882
  */
1875
1883
  get: (documentId, includeBody, asOf, layers) =>
1876
1884
  this._request("GET", `/api/v1/documents/${documentId}`, {
1877
- bypassBatch: true,
1878
1885
  queryParams: {
1879
1886
  "include-body": includeBody,
1880
1887
  "as-of": asOf,
@@ -2071,7 +2078,6 @@ class PlaidClient {
2071
2078
  */
2072
2079
  myLastEdits: (projectId) =>
2073
2080
  this._request("GET", `/api/v1/projects/${projectId}/audit/last-edits`, {
2074
- bypassBatch: true,
2075
2081
  skipResponseTransform: true,
2076
2082
  }),
2077
2083
  /**
@@ -2100,7 +2106,6 @@ class PlaidClient {
2100
2106
  */
2101
2107
  get: (id, asOf) =>
2102
2108
  this._request("GET", `/api/v1/projects/${id}`, {
2103
- bypassBatch: true,
2104
2109
  queryParams: { "as-of": asOf },
2105
2110
  }),
2106
2111
  /**
@@ -2247,7 +2252,6 @@ class PlaidClient {
2247
2252
  */
2248
2253
  get: (textLayerId, asOf) =>
2249
2254
  this._request("GET", `/api/v1/text-layers/${textLayerId}`, {
2250
- bypassBatch: true,
2251
2255
  queryParams: { "as-of": asOf },
2252
2256
  }),
2253
2257
  /**
@@ -2343,9 +2347,23 @@ class PlaidClient {
2343
2347
  auditMessage,
2344
2348
  body,
2345
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
+ }),
2346
2361
  /**
2347
2362
  * Delete multiple vocab items in a single operation. Each item's
2348
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.
2349
2367
  * @param {string[]} body - The vocab item IDs to delete
2350
2368
  */
2351
2369
  bulkDelete: (body, auditMessage) =>
@@ -2360,17 +2378,21 @@ class PlaidClient {
2360
2378
  */
2361
2379
  get: (id, asOf) =>
2362
2380
  this._request("GET", `/api/v1/vocab-items/${id}`, {
2363
- bypassBatch: true,
2364
2381
  queryParams: { "as-of": asOf },
2365
2382
  }),
2366
2383
  /**
2367
- * 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.
2368
2387
  * @param {string} id - The resource ID
2369
2388
  */
2370
2389
  delete: (id, auditMessage) =>
2371
2390
  this._request("DELETE", `/api/v1/vocab-items/${id}`, { auditMessage }),
2372
2391
  /**
2373
- * 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.
2374
2396
  * @param {string} id - The resource ID
2375
2397
  * @param {string} form - The vocab item form
2376
2398
  */
@@ -2441,7 +2463,6 @@ class PlaidClient {
2441
2463
  */
2442
2464
  get: (relationLayerId, asOf) =>
2443
2465
  this._request("GET", `/api/v1/relation-layers/${relationLayerId}`, {
2444
- bypassBatch: true,
2445
2466
  queryParams: { "as-of": asOf },
2446
2467
  }),
2447
2468
  /**
@@ -2510,7 +2531,6 @@ class PlaidClient {
2510
2531
  */
2511
2532
  get: (tokenId, asOf) =>
2512
2533
  this._request("GET", `/api/v1/tokens/${tokenId}`, {
2513
- bypassBatch: true,
2514
2534
  queryParams: { "as-of": asOf },
2515
2535
  }),
2516
2536
  /**
@@ -2661,8 +2681,7 @@ class PlaidClient {
2661
2681
  /**
2662
2682
  * Discover the services seen on a project (synchronous GET). Currently
2663
2683
  * connected services carry `online: true`; previously-seen offline ones
2664
- * carry `online: false` plus a `lastSeenAt` stamp. Goes over the wire
2665
- * even while a batch is open on the client.
2684
+ * carry `online: false` plus a `lastSeenAt` stamp.
2666
2685
  * @param {string} projectId - The UUID of the project to query
2667
2686
  * @returns {Promise<Array>} Array of discovered service information
2668
2687
  */
@@ -2811,17 +2830,14 @@ class PlaidClient {
2811
2830
  * For 'count': {return: 'count', count}. Entity cells are full entity objects
2812
2831
  * (same shape as the GET endpoints).
2813
2832
  */
2814
- // bypassBatch: a query is a read. `client.batched()` flips one flag on the
2815
- // whole client, so a query issued by unrelated code while a batch is open
2816
- // (the IGT island fetching precedent while a document reconciles) used to
2817
- // be queued into it: the caller got `{batched: true}` instead of results,
2818
- // and the server ran the query against the batch's tx Connection, which
2819
- // 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.
2820
2836
  this.query = (body, auditMessage) =>
2821
2837
  this._request("POST", "/api/v1/query", {
2822
2838
  auditMessage,
2823
2839
  body,
2824
- bypassBatch: true,
2840
+ outOfBand: true,
2825
2841
  });
2826
2842
 
2827
2843
  // Logical-operation groups (audit-log grouping). There is no create: a
@@ -2832,10 +2848,7 @@ class PlaidClient {
2832
2848
  * Get a logical-operation group (its label + creator).
2833
2849
  * @param {string} id - The group id
2834
2850
  */
2835
- get: (id) =>
2836
- this._request("GET", `/api/v1/operation-groups/${id}`, {
2837
- bypassBatch: true,
2838
- }),
2851
+ get: (id) => this._request("GET", `/api/v1/operation-groups/${id}`),
2839
2852
  /**
2840
2853
  * Relabel a logical-operation group after the fact. Owner or admin only.
2841
2854
  * @param {string} id - The group id
@@ -2876,8 +2889,7 @@ class PlaidClient {
2876
2889
  * Read one comment.
2877
2890
  * @param {string} id - The comment id
2878
2891
  */
2879
- get: (id) =>
2880
- this._request("GET", `/api/v1/comments/${id}`, { bypassBatch: true }),
2892
+ get: (id) => this._request("GET", `/api/v1/comments/${id}`),
2881
2893
  /**
2882
2894
  * Edit a comment's body. Only the comment's AUTHOR may do this - not
2883
2895
  * maintainers, not admins. Sets `edited` on the comment.
@@ -2969,7 +2981,6 @@ class PlaidClient {
2969
2981
  */
2970
2982
  counts: (projectId, { documentId, entityType, entityId } = {}) =>
2971
2983
  this._request("GET", `/api/v1/projects/${projectId}/comments/counts`, {
2972
- bypassBatch: true,
2973
2984
  queryParams: {
2974
2985
  "document-id": documentId,
2975
2986
  "entity-type": entityType,
@@ -3017,12 +3028,111 @@ class PlaidClient {
3017
3028
  "GET",
3018
3029
  `/api/v1/vocab-layers/${vocabId}/comments/counts`,
3019
3030
  {
3020
- bypassBatch: true,
3021
3031
  queryParams: { "entity-id": entityId },
3022
3032
  skipResponseTransform: true,
3023
3033
  },
3024
3034
  ),
3025
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
+ };
3026
3136
  }
3027
3137
 
3028
3138
  // --- Core methods ---
@@ -3124,7 +3234,7 @@ class PlaidClient {
3124
3234
  * callback to refine the label once the outcome is known.
3125
3235
  *
3126
3236
  * await client.withOperation('Merge morphemes', async (setMessage) => {
3127
- * await client.batched(() => { ... });
3237
+ * await client.batched(async (b) => { ... });
3128
3238
  * setMessage(`Merged ${n} morphemes`);
3129
3239
  * });
3130
3240
  *
@@ -3146,55 +3256,28 @@ class PlaidClient {
3146
3256
  }
3147
3257
 
3148
3258
  /**
3149
- * Begin a batch of operations. Subsequent WRITES are queued; reads and
3150
- * out-of-band signals still go over the wire (see `batched`).
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
3151
3278
  */
3152
- beginBatch() {
3153
- this.isBatching = true;
3154
- this.batchOperations = [];
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.
3157
- this.batchVersionStamped = false;
3158
- }
3159
-
3160
- /**
3161
- * Submit all queued batch operations as a single batch request, executed
3162
- * atomically. If any operation fails, all changes are rolled back.
3163
- * @returns {Promise<Array>} Array of results corresponding to each operation
3164
- */
3165
- async submitBatch() {
3166
- if (!this.isBatching) {
3167
- throw new Error("No active batch. Call beginBatch() first.");
3168
- }
3169
-
3170
- if (this.batchOperations.length === 0) {
3171
- this.isBatching = false;
3172
- return [];
3173
- }
3174
-
3175
- try {
3176
- const url = `${this.baseUrl}/api/v1/batch`;
3177
- const ops = this.batchOperations.map((op) => ({
3178
- path: op.path,
3179
- method: op.method.toUpperCase(),
3180
- ...(op.body && { body: op.body }),
3181
- }));
3182
- // The server caps a batch at MAX_BATCH_OPS so one transaction cannot
3183
- // hold the write lock without bound. A larger batch goes as consecutive
3184
- // requests, results concatenated in queue order: it could not have been
3185
- // one transaction anyway, and a repair or bulk edit over a big document
3186
- // must not fail on its size alone.
3187
- const results = [];
3188
- for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
3189
- results.push(
3190
- ...(await this._postBatch(url, ops.slice(i, i + MAX_BATCH_OPS))),
3191
- );
3192
- }
3193
- return results;
3194
- } finally {
3195
- this.isBatching = false;
3196
- this.batchOperations = [];
3197
- }
3279
+ batch() {
3280
+ return openBatch(this);
3198
3281
  }
3199
3282
 
3200
3283
  /** POST one batch request (at most MAX_BATCH_OPS operations); resolves to its transformed results. */
@@ -3256,62 +3339,31 @@ class PlaidClient {
3256
3339
  }
3257
3340
  }
3258
3341
 
3259
- /** Abort the current batch without executing any operations. */
3260
- abortBatch() {
3261
- this.isBatching = false;
3262
- this.batchOperations = [];
3263
- }
3264
-
3265
3342
  /**
3266
- * Check if currently in batch mode.
3267
- * @returns {boolean}
3268
- */
3269
- isBatchMode() {
3270
- return this.isBatching;
3271
- }
3272
-
3273
- /**
3274
- * Run `fn` with a batch open, then submit all queued ops as ONE atomic
3275
- * request — or abort the batch if `fn` throws, so a half-open batch can never
3276
- * silently swallow later non-batch calls. `fn` makes the (queued) client
3277
- * calls; it must NOT call submitBatch itself. Resolves to the batch results
3278
- * 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).
3279
3347
  *
3280
- * const [sentRes, wordRes] = await client.batched(async () => {
3281
- * client.tokens.bulkCreate(sentenceOps);
3282
- * client.tokens.bulkCreate(wordOps);
3348
+ * const [sentRes, wordRes] = await client.batched(async (b) => {
3349
+ * b.tokens.bulkCreate(sentenceOps);
3350
+ * b.tokens.bulkCreate(wordOps);
3283
3351
  * });
3284
3352
  *
3285
- * Server-side a batch runs sequentially in one transaction (a child op sees
3286
- * parents created earlier in the same `fn`; any op's failure rolls the whole
3287
- * batch back). Not nestable. Named `batched()` because `client.batch` is the
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.
3301
- * @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
3302
3356
  * @returns {Promise<Array>}
3303
3357
  */
3304
3358
  async batched(fn) {
3305
- this.beginBatch();
3359
+ const batch = this.batch();
3306
3360
  try {
3307
- await fn();
3361
+ await fn(batch);
3308
3362
  } catch (e) {
3309
- // fn failed: drop the queued ops so the client leaves batch mode and
3310
- // later plain calls don't queue into a never-submitted batch.
3311
- if (this.isBatchMode()) this.abortBatch();
3363
+ batch.abort();
3312
3364
  throw e;
3313
3365
  }
3314
- return this.submitBatch();
3366
+ return batch.submit();
3315
3367
  }
3316
3368
 
3317
3369
  /**