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

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,8 @@
5
5
  import { transformRequest, transformResponse } from "./transforms.js";
6
6
  import {
7
7
  makeRequest,
8
+ queueRequest,
9
+ restampDocumentVersion,
8
10
  extractDocumentVersions,
9
11
  parseErrorBody,
10
12
  makeHttpError,
@@ -117,6 +119,93 @@ async function anonymousGet(baseUrl, path, options = {}) {
117
119
  */
118
120
  const MAX_BATCH_OPS = 1000;
119
121
 
122
+ /**
123
+ * A batch is the client with a different `_request` (see `queueRequest`) and
124
+ * its own copy of the bundles. Everything else resolves to the client through
125
+ * the prototype: the token and base URL, strict mode and the document versions
126
+ * it tracks, the open logical operation.
127
+ */
128
+ function openBatch(client) {
129
+ const batch = Object.create(client);
130
+ batch.client = client;
131
+ batch.operations = [];
132
+ // The document each queued op's strict-mode stamp is for (null for none),
133
+ // index for index with `operations`.
134
+ batch.stampedDocuments = [];
135
+ batch.open = true;
136
+ batch._request = (method, path, options = {}) =>
137
+ queueRequest(batch, method, path, options);
138
+ batch.batch = () => {
139
+ throw new Error("A batch is not nestable: queue on the batch you have");
140
+ };
141
+ batch.batched = batch.batch;
142
+ batch.submit = () => submitBatch(batch);
143
+ batch.abort = () => {
144
+ batch.operations = [];
145
+ batch.stampedDocuments = [];
146
+ batch.open = false;
147
+ };
148
+ batch._installResources();
149
+ return batch;
150
+ }
151
+
152
+ async function submitBatch(batch) {
153
+ if (!batch.open) {
154
+ throw new Error("This batch was already submitted or aborted");
155
+ }
156
+ batch.open = false;
157
+ const ops = batch.operations;
158
+ const stamps = batch.stampedDocuments;
159
+ batch.operations = [];
160
+ batch.stampedDocuments = [];
161
+ if (ops.length === 0) return [];
162
+ const client = batch.client;
163
+ const url = `${client.baseUrl}/api/v1/batch`;
164
+ // The server caps a batch at MAX_BATCH_OPS so one transaction cannot hold
165
+ // the write lock without bound. A larger batch goes as consecutive
166
+ // requests, results concatenated in queue order: it could not have been one
167
+ // transaction anyway, and a repair or bulk edit over a big document must
168
+ // not fail on its size alone. Past the first request, a strict-mode stamp
169
+ // claims the version the requests before it left (learned from their
170
+ // results), not the one the document had when the op was queued, which the
171
+ // first request has already moved on.
172
+ //
173
+ // Each request is atomic, the whole is not: a failure leaves the requests
174
+ // before it saved. The error says so, as `committed` (how many operations
175
+ // were saved) and `committedResults` (their results, in queue order), so a
176
+ // caller can tell a partial write from a batch that saved nothing. The
177
+ // failed request itself counts as unsaved, even when its answer was lost.
178
+ const results = [];
179
+ try {
180
+ await sendChunks(client, url, ops, stamps, results);
181
+ } catch (error) {
182
+ if (error && typeof error === "object") {
183
+ error.committed = results.length;
184
+ error.committedResults = results;
185
+ }
186
+ throw error;
187
+ }
188
+ return results;
189
+ }
190
+
191
+ async function sendChunks(client, url, ops, stamps, results) {
192
+ for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
193
+ let chunk = ops.slice(i, i + MAX_BATCH_OPS);
194
+ if (i > 0) {
195
+ chunk = chunk.map((op, j) => {
196
+ const docId = stamps[i + j];
197
+ if (!docId) return op;
198
+ const path = restampDocumentVersion(
199
+ op.path,
200
+ client.documentVersions[docId],
201
+ );
202
+ return { ...op, path };
203
+ });
204
+ }
205
+ results.push(...(await client._postBatch(url, chunk)));
206
+ }
207
+ }
208
+
120
209
  class PlaidClient {
121
210
  /**
122
211
  * Create a new PlaidClient instance
@@ -142,8 +231,6 @@ class PlaidClient {
142
231
  : options.timeout !== undefined
143
232
  ? options.timeout
144
233
  : DEFAULT_BATCH_TIMEOUT_MS;
145
- this.isBatching = false;
146
- this.batchOperations = [];
147
234
  this.documentVersions = {};
148
235
  this.strictModeDocumentId = null;
149
236
  // Set to a DocumentLockLost while a `documents.locked()` block's keep-alive
@@ -161,8 +248,16 @@ class PlaidClient {
161
248
  this.onAuthError = options.onAuthError || null;
162
249
  this._authErrorFired = false;
163
250
 
164
- // --- API Bundles ---
251
+ this._installResources();
252
+ }
165
253
 
254
+ /**
255
+ * The API bundles (`client.documents`, `client.tokens`, ...). Installed on
256
+ * the client, and again on every batch it opens (see `batch()`): a batch is
257
+ * a view of the client with its own `_request`, so the same bundles built
258
+ * on it queue instead of sending.
259
+ */
260
+ _installResources() {
166
261
  this.vocabLinks = {
167
262
  /**
168
263
  * Create a new vocab link between tokens and a vocab item.
@@ -205,7 +300,6 @@ class PlaidClient {
205
300
  this._request("PUT", `/api/v1/vocab-links/${id}/metadata`, {
206
301
  auditMessage,
207
302
  rawBody: body,
208
- skipResponseTransform: true,
209
303
  }),
210
304
  /**
211
305
  * Remove all metadata from a vocab link.
@@ -214,29 +308,23 @@ class PlaidClient {
214
308
  deleteMetadata: (id, auditMessage) =>
215
309
  this._request("DELETE", `/api/v1/vocab-links/${id}/metadata`, {
216
310
  auditMessage,
217
- skipResponseTransform: true,
218
311
  }),
219
312
  /**
220
- * Patch (shallow-merge) metadata for a vocab link. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
313
+ * Edit metadata for a vocab link with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
221
314
  * @param {string} id - The resource ID
222
- * @param {any} body - The metadata patch
315
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
223
316
  */
224
317
  patchMetadata: (id, body, auditMessage) =>
225
318
  this._request("PATCH", `/api/v1/vocab-links/${id}/metadata`, {
226
319
  auditMessage,
227
320
  rawBody: body,
228
- skipResponseTransform: true,
229
321
  }),
230
322
  /**
231
323
  * Get a vocab link by ID
232
324
  * @param {string} id - The resource ID
233
- * @param {string} [asOf] - Temporal query timestamp
234
325
  */
235
- get: (id, asOf) =>
236
- this._request("GET", `/api/v1/vocab-links/${id}`, {
237
- bypassBatch: true,
238
- queryParams: { "as-of": asOf },
239
- }),
326
+ get: (id) =>
327
+ this._request("GET", `/api/v1/vocab-links/${id}`),
240
328
  /**
241
329
  * Delete a vocab link
242
330
  * @param {string} id - The resource ID
@@ -247,16 +335,113 @@ class PlaidClient {
247
335
 
248
336
  this.vocabLayers = {
249
337
  /**
250
- * Get a vocab layer by ID
338
+ * Get a vocab layer by ID. With `asOf`, the vocabulary as it was at that
339
+ * instant, read from its history: the same shape as the live read, its
340
+ * entries with `includeItems`. A vocabulary that did not exist then is a
341
+ * 404, a malformed or pruned time a 400.
251
342
  * @param {string} id - The resource ID
252
343
  * @param {boolean} [includeItems] - Include vocab items
253
- * @param {string} [asOf] - Temporal query timestamp
344
+ * @param {string} [asOf] - The moment to read at (ISO-8601 instant)
254
345
  */
255
346
  get: (id, includeItems, asOf) =>
256
347
  this._request("GET", `/api/v1/vocab-layers/${id}`, {
257
- bypassBatch: true,
258
348
  queryParams: { "include-items": includeItems, "as-of": asOf },
259
349
  }),
350
+ /**
351
+ * One entry of the vocabulary as it was at `asOf`, also when it has
352
+ * been deleted since. The same shape as `vocabItems.get`. A 404 when
353
+ * the entry was not in this vocabulary at that time.
354
+ * @param {string} id - The vocabulary ID
355
+ * @param {string} itemId - The entry ID
356
+ * @param {string} asOf - The moment to read at (ISO-8601 instant)
357
+ */
358
+ getItemAt: (id, itemId, asOf) =>
359
+ this._request("GET", `/api/v1/vocab-layers/${id}/items/${itemId}`, {
360
+ queryParams: { "as-of": asOf },
361
+ }),
362
+ /**
363
+ * Get the audit log of a vocabulary: every change to it or to its
364
+ * entries, folded into entries as the document log is. Links are not
365
+ * listed, they belong to the document they annotate. Transparently
366
+ * follows pagination cursors and returns the full flat array.
367
+ * @param {string} id - The vocabulary ID
368
+ * @param {string} [startTime] - Start of time range
369
+ * @param {string} [endTime] - End of time range
370
+ * @param {string[]|string} [opTypes] - Only return operations of these
371
+ * types, spelled as in an entry's `op/type` (e.g.
372
+ * `['vocab-item/delete', 'vocab-item/restore']`)
373
+ * @param {string} [itemId] - Only the changes that wrote this one entry,
374
+ * each with only its operations that did
375
+ */
376
+ audit: (id, startTime, endTime, opTypes, itemId) =>
377
+ listAll(this, `/api/v1/vocab-layers/${id}/audit`, {
378
+ query: {
379
+ "start-time": startTime,
380
+ "end-time": endTime,
381
+ "op-types": opTypesParam(opTypes),
382
+ "item-id": itemId,
383
+ },
384
+ }),
385
+ /**
386
+ * One page of the same log, newest-first with `order: "desc"`. Use this
387
+ * rather than audit() wherever the caller wants the recent end of a log
388
+ * that may be long: audit() walks every page before it resolves.
389
+ * @param {string} id - The vocabulary ID
390
+ * @param {object} [opts]
391
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
392
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
393
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
394
+ * @param {string} [opts.itemId] - Only the changes that wrote this one
395
+ * entry, each with only its operations that did
396
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
397
+ */
398
+ auditPage: (
399
+ id,
400
+ { startTime, endTime, opTypes, order, limit, cursor, itemId } = {},
401
+ ) =>
402
+ listPage(this, `/api/v1/vocab-layers/${id}/audit`, {
403
+ limit,
404
+ cursor,
405
+ query: {
406
+ "start-time": startTime,
407
+ "end-time": endTime,
408
+ "op-types": opTypesParam(opTypes),
409
+ order,
410
+ "item-id": itemId,
411
+ },
412
+ }),
413
+ /**
414
+ * Put one entry of the vocabulary back as it was at `asOf`, as one
415
+ * operation. A deleted entry comes back under its original id with its
416
+ * form and fields, and a living one has its form and fields set back.
417
+ * Links are not part of an entry: a deleted entry's links come back
418
+ * through each document's own `documents.restore`. Resolves to
419
+ * `{inserted, form, metadata, total}`, `total` 0 when nothing changes.
420
+ * A form set back bumps every linking document, and the new versions
421
+ * come back in X-Document-Versions (or, past fifty documents,
422
+ * X-Document-Versions-Omitted), which the client takes up as on any
423
+ * write. A time when the entry did not exist is a 400. Maintainers of
424
+ * the vocabulary only.
425
+ * @param {string} id - The vocabulary ID
426
+ * @param {string} itemId - The entry ID
427
+ * @param {string} asOf - The moment to go back to (ISO-8601 instant),
428
+ * typically a history entry's `endTime`
429
+ * @param {object} [options] - `{ dryRun }`: with `dryRun` true nothing
430
+ * is written and the summary says what would change
431
+ * @param {string} [auditMessage] - Custom audit message for this operation
432
+ */
433
+ restoreItem: (id, itemId, asOf, { dryRun } = {}, auditMessage) =>
434
+ this._request(
435
+ "POST",
436
+ `/api/v1/vocab-layers/${id}/items/${itemId}/restore`,
437
+ {
438
+ queryParams: {
439
+ "as-of": asOf,
440
+ "dry-run": dryRun ? "true" : undefined,
441
+ },
442
+ auditMessage,
443
+ },
444
+ ),
260
445
  /**
261
446
  * Delete a vocab layer.
262
447
  * @param {string} id - The resource ID
@@ -301,35 +486,29 @@ class PlaidClient {
301
486
  /**
302
487
  * List all vocab layers accessible to user. Transparently follows
303
488
  * pagination cursors and returns the full flat array.
304
- * @param {string} [asOf] - Temporal query timestamp
305
489
  */
306
- list: (asOf) =>
307
- listAll(this, "/api/v1/vocab-layers", { query: { "as-of": asOf } }),
490
+ list: () => listAll(this, "/api/v1/vocab-layers"),
308
491
  /**
309
492
  * Fetch a single page of vocab layers.
310
493
  * @param {object} [opts]
311
494
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
312
495
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
313
- * @param {string} [opts.asOf] - Temporal query timestamp
314
496
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
315
497
  */
316
- listPage: ({ limit, cursor, asOf } = {}) =>
498
+ listPage: ({ limit, cursor } = {}) =>
317
499
  listPage(this, "/api/v1/vocab-layers", {
318
500
  limit,
319
501
  cursor,
320
- query: { "as-of": asOf },
321
502
  }),
322
503
  /**
323
504
  * Async-iterate vocab layers page by page; yields each page's entries array.
324
505
  * @param {object} [opts]
325
506
  * @param {number} [opts.pageSize] - Per-request page size
326
- * @param {string} [opts.asOf] - Temporal query timestamp
327
507
  * @returns {AsyncGenerator<Array>}
328
508
  */
329
- iterPages: ({ pageSize, asOf } = {}) =>
509
+ iterPages: ({ pageSize } = {}) =>
330
510
  iterPages(this, "/api/v1/vocab-layers", {
331
511
  pageSize,
332
- query: { "as-of": asOf },
333
512
  }),
334
513
  /**
335
514
  * Create a new vocab layer. Note: this also registers the user as a maintainer.
@@ -374,7 +553,6 @@ class PlaidClient {
374
553
  this._request("PUT", `/api/v1/relations/${relationId}/metadata`, {
375
554
  auditMessage,
376
555
  rawBody: body,
377
- skipResponseTransform: true,
378
556
  }),
379
557
  /**
380
558
  * Remove all metadata from a relation.
@@ -383,18 +561,16 @@ class PlaidClient {
383
561
  deleteMetadata: (relationId, auditMessage) =>
384
562
  this._request("DELETE", `/api/v1/relations/${relationId}/metadata`, {
385
563
  auditMessage,
386
- skipResponseTransform: true,
387
564
  }),
388
565
  /**
389
- * Patch (shallow-merge) metadata for a relation. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
566
+ * Edit metadata for a relation with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
390
567
  * @param {string} relationId - The relation ID
391
- * @param {any} body - The metadata patch
568
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
392
569
  */
393
570
  patchMetadata: (relationId, body, auditMessage) =>
394
571
  this._request("PATCH", `/api/v1/relations/${relationId}/metadata`, {
395
572
  auditMessage,
396
573
  rawBody: body,
397
- skipResponseTransform: true,
398
574
  }),
399
575
  /**
400
576
  * Update the target span of a relation.
@@ -409,13 +585,9 @@ class PlaidClient {
409
585
  /**
410
586
  * Get a relation by ID.
411
587
  * @param {string} relationId - The relation ID
412
- * @param {string} [asOf] - Temporal query timestamp
413
588
  */
414
- get: (relationId, asOf) =>
415
- this._request("GET", `/api/v1/relations/${relationId}`, {
416
- bypassBatch: true,
417
- queryParams: { "as-of": asOf },
418
- }),
589
+ get: (relationId) =>
590
+ this._request("GET", `/api/v1/relations/${relationId}`),
419
591
  /**
420
592
  * Delete a relation.
421
593
  * @param {string} relationId - The relation ID
@@ -483,7 +655,7 @@ class PlaidClient {
483
655
  }),
484
656
  /**
485
657
  * 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.
658
+ * @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 list of metadata ops, as for patchMetadata. 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` (past fifty documents, only their number, in `X-Document-Versions-Omitted`). A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
487
659
  * @returns {Promise<{count: number}>} How many relations were updated.
488
660
  */
489
661
  bulkUpdate: (body, auditMessage) =>
@@ -528,13 +700,9 @@ class PlaidClient {
528
700
  /**
529
701
  * Get a span layer by ID.
530
702
  * @param {string} spanLayerId - The span layer ID
531
- * @param {string} [asOf] - Temporal query timestamp
532
703
  */
533
- get: (spanLayerId, asOf) =>
534
- this._request("GET", `/api/v1/span-layers/${spanLayerId}`, {
535
- bypassBatch: true,
536
- queryParams: { "as-of": asOf },
537
- }),
704
+ get: (spanLayerId) =>
705
+ this._request("GET", `/api/v1/span-layers/${spanLayerId}`),
538
706
  /**
539
707
  * Delete a span layer.
540
708
  * @param {string} spanLayerId - The span layer ID
@@ -607,13 +775,9 @@ class PlaidClient {
607
775
  /**
608
776
  * Get a span by ID.
609
777
  * @param {string} spanId - The span ID
610
- * @param {string} [asOf] - Temporal query timestamp
611
778
  */
612
- get: (spanId, asOf) =>
613
- this._request("GET", `/api/v1/spans/${spanId}`, {
614
- bypassBatch: true,
615
- queryParams: { "as-of": asOf },
616
- }),
779
+ get: (spanId) =>
780
+ this._request("GET", `/api/v1/spans/${spanId}`),
617
781
  /**
618
782
  * Delete a span.
619
783
  * @param {string} spanId - The span ID
@@ -645,7 +809,7 @@ class PlaidClient {
645
809
  this._request("DELETE", "/api/v1/spans/bulk", { auditMessage, body }),
646
810
  /**
647
811
  * 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.
812
+ * @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 list of metadata ops, as for patchMetadata. 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` (past fifty documents, only their number, in `X-Document-Versions-Omitted`). A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
649
813
  * @returns {Promise<{count: number}>} How many spans were updated.
650
814
  */
651
815
  bulkUpdate: (body, auditMessage) =>
@@ -662,7 +826,6 @@ class PlaidClient {
662
826
  this._request("PUT", `/api/v1/spans/${spanId}/metadata`, {
663
827
  auditMessage,
664
828
  rawBody: body,
665
- skipResponseTransform: true,
666
829
  }),
667
830
  /**
668
831
  * Remove all metadata from a span.
@@ -671,18 +834,16 @@ class PlaidClient {
671
834
  deleteMetadata: (spanId, auditMessage) =>
672
835
  this._request("DELETE", `/api/v1/spans/${spanId}/metadata`, {
673
836
  auditMessage,
674
- skipResponseTransform: true,
675
837
  }),
676
838
  /**
677
- * Patch (shallow-merge) metadata for a span. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
839
+ * Edit metadata for a span with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
678
840
  * @param {string} spanId - The span ID
679
- * @param {any} body - The metadata patch
841
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
680
842
  */
681
843
  patchMetadata: (spanId, body, auditMessage) =>
682
844
  this._request("PATCH", `/api/v1/spans/${spanId}/metadata`, {
683
845
  auditMessage,
684
846
  rawBody: body,
685
- skipResponseTransform: true,
686
847
  }),
687
848
  };
688
849
 
@@ -698,11 +859,7 @@ class PlaidClient {
698
859
  */
699
860
  info: () => {
700
861
  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) => {
862
+ infoPromise = this._request("GET", "/api/v1/info").catch((err) => {
706
863
  // A failure must not be cached: a client that starts before the
707
864
  // server is up would never see the limits at all.
708
865
  infoPromise = null;
@@ -722,18 +879,22 @@ class PlaidClient {
722
879
  health: () => PlaidClient.health(this.baseUrl, { timeout: this.timeout }),
723
880
  };
724
881
 
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
882
+ // The signed-in user's sign-ins. Signing in is `PlaidClient.login`, which
883
+ // makes the client.
884
+ this.auth = {
885
+ /**
886
+ * End every sign-in of this user on every device: each browser tab,
887
+ * script and service holding one of the user's sign-in tokens is
888
+ * refused from now on, this client included. Named API tokens are not
889
+ * affected (revoke one with `apiTokens.revoke`). To sign out of one tab
890
+ * only, discard its token instead. A signal rather than project data:
891
+ * made on a batch it still goes out at once, and it never joins an
892
+ * operation.
893
+ * @returns {Promise<void>}
730
894
  */
731
- submit: (body, auditMessage) =>
732
- this._request("POST", "/api/v1/batch", {
733
- auditMessage,
734
- body,
735
- noBatch: true,
736
- }),
895
+ logoutEverywhere: async () => {
896
+ await this._request("POST", "/api/v1/logout", { outOfBand: true });
897
+ },
737
898
  };
738
899
 
739
900
  this.texts = {
@@ -746,7 +907,6 @@ class PlaidClient {
746
907
  this._request("PUT", `/api/v1/texts/${textId}/metadata`, {
747
908
  auditMessage,
748
909
  rawBody: body,
749
- skipResponseTransform: true,
750
910
  }),
751
911
  /**
752
912
  * Remove all metadata from a text.
@@ -755,18 +915,16 @@ class PlaidClient {
755
915
  deleteMetadata: (textId, auditMessage) =>
756
916
  this._request("DELETE", `/api/v1/texts/${textId}/metadata`, {
757
917
  auditMessage,
758
- skipResponseTransform: true,
759
918
  }),
760
919
  /**
761
- * Patch (shallow-merge) metadata for a text. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
920
+ * Edit metadata for a text with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
762
921
  * @param {string} textId - The text ID
763
- * @param {any} body - The metadata patch
922
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
764
923
  */
765
924
  patchMetadata: (textId, body, auditMessage) =>
766
925
  this._request("PATCH", `/api/v1/texts/${textId}/metadata`, {
767
926
  auditMessage,
768
927
  rawBody: body,
769
- skipResponseTransform: true,
770
928
  }),
771
929
  /**
772
930
  * Create a new text in a document's text layer. A text is a container for
@@ -789,13 +947,9 @@ class PlaidClient {
789
947
  /**
790
948
  * Get a text.
791
949
  * @param {string} textId - The text ID
792
- * @param {string} [asOf] - Temporal query timestamp
793
950
  */
794
- get: (textId, asOf) =>
795
- this._request("GET", `/api/v1/texts/${textId}`, {
796
- bypassBatch: true,
797
- queryParams: { "as-of": asOf },
798
- }),
951
+ get: (textId) =>
952
+ this._request("GET", `/api/v1/texts/${textId}`),
799
953
  /**
800
954
  * Delete a text and all dependent data.
801
955
  * @param {string} textId - The text ID
@@ -827,37 +981,34 @@ class PlaidClient {
827
981
  * returns the full flat array. Admin-or-maintainer only.
828
982
  * @param {object} [opts]
829
983
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
830
- * @param {string} [opts.asOf] - Temporal query timestamp
831
984
  */
832
- list: ({ q, asOf } = {}) =>
833
- listAll(this, "/api/v1/users", { query: { q, "as-of": asOf } }),
985
+ list: ({ q } = {}) =>
986
+ listAll(this, "/api/v1/users", { query: { q } }),
834
987
  /**
835
988
  * Fetch a single page of users (optionally filtered by `q`).
836
989
  * @param {object} [opts]
837
990
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
838
991
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
839
992
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
840
- * @param {string} [opts.asOf] - Temporal query timestamp
841
993
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
842
994
  */
843
- listPage: ({ q, limit, cursor, asOf } = {}) =>
995
+ listPage: ({ q, limit, cursor } = {}) =>
844
996
  listPage(this, "/api/v1/users", {
845
997
  limit,
846
998
  cursor,
847
- query: { q, "as-of": asOf },
999
+ query: { q },
848
1000
  }),
849
1001
  /**
850
1002
  * Async-iterate users page by page; yields each page's entries array.
851
1003
  * @param {object} [opts]
852
1004
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
853
1005
  * @param {number} [opts.pageSize] - Per-request page size
854
- * @param {string} [opts.asOf] - Temporal query timestamp
855
1006
  * @returns {AsyncGenerator<Array>}
856
1007
  */
857
- iterPages: ({ q, pageSize, asOf } = {}) =>
1008
+ iterPages: ({ q, pageSize } = {}) =>
858
1009
  iterPages(this, "/api/v1/users", {
859
1010
  pageSize,
860
- query: { q, "as-of": asOf },
1011
+ query: { q },
861
1012
  }),
862
1013
  /**
863
1014
  * Create a new user.
@@ -875,7 +1026,9 @@ class PlaidClient {
875
1026
  email,
876
1027
  password,
877
1028
  "is-admin": isAdmin,
878
- "display-name": displayName,
1029
+ // Left out when not given (null too), so core gives the user the
1030
+ // local part of the email. Core refuses a null display name.
1031
+ "display-name": displayName ?? undefined,
879
1032
  }),
880
1033
  }),
881
1034
  /**
@@ -884,18 +1037,16 @@ class PlaidClient {
884
1037
  * @param {string} userId - The user ID
885
1038
  * @param {string} [startTime] - Start of time range
886
1039
  * @param {string} [endTime] - End of time range
887
- * @param {string} [asOf] - Temporal query timestamp
888
1040
  * @param {string[]|string} [opTypes] - Only return operations of these
889
1041
  * types, spelled as in an entry's `op/type` (e.g.
890
1042
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
891
1043
  * one of its operations matches, carrying only the ones that did.
892
1044
  */
893
- audit: (userId, startTime, endTime, asOf, opTypes) =>
1045
+ audit: (userId, startTime, endTime, opTypes) =>
894
1046
  listAll(this, `/api/v1/users/${userId}/audit`, {
895
1047
  query: {
896
1048
  "start-time": startTime,
897
1049
  "end-time": endTime,
898
- "as-of": asOf,
899
1050
  "op-types": opTypesParam(opTypes),
900
1051
  },
901
1052
  }),
@@ -903,6 +1054,7 @@ class PlaidClient {
903
1054
  * One page of the same log, newest-first with `order: "desc"`. Use this
904
1055
  * rather than audit() wherever the caller wants the recent end of a log
905
1056
  * that may be long: audit() walks every page before it resolves.
1057
+ * @param {string} userId - The user ID
906
1058
  * @param {object} [opts]
907
1059
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
908
1060
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
@@ -911,7 +1063,7 @@ class PlaidClient {
911
1063
  */
912
1064
  auditPage: (
913
1065
  userId,
914
- { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
1066
+ { startTime, endTime, opTypes, order, limit, cursor } = {},
915
1067
  ) =>
916
1068
  listPage(this, `/api/v1/users/${userId}/audit`, {
917
1069
  limit,
@@ -919,7 +1071,6 @@ class PlaidClient {
919
1071
  query: {
920
1072
  "start-time": startTime,
921
1073
  "end-time": endTime,
922
- "as-of": asOf,
923
1074
  "op-types": opTypesParam(opTypes),
924
1075
  order,
925
1076
  },
@@ -927,13 +1078,9 @@ class PlaidClient {
927
1078
  /**
928
1079
  * Get a user by ID
929
1080
  * @param {string} id - The resource ID
930
- * @param {string} [asOf] - Temporal query timestamp
931
1081
  */
932
- get: (id, asOf) =>
933
- this._request("GET", `/api/v1/users/${id}`, {
934
- bypassBatch: true,
935
- queryParams: { "as-of": asOf },
936
- }),
1082
+ get: (id) =>
1083
+ this._request("GET", `/api/v1/users/${id}`),
937
1084
  /**
938
1085
  * Deactivate a user. Users are never hard-deleted: deactivation
939
1086
  * rejects their logins and tokens, strips their project memberships
@@ -1001,7 +1148,6 @@ class PlaidClient {
1001
1148
  */
1002
1149
  getAvatar: (id) =>
1003
1150
  this._request("GET", `/api/v1/users/${id}/avatar`, {
1004
- bypassBatch: true,
1005
1151
  binaryResponse: true,
1006
1152
  }),
1007
1153
  /**
@@ -1059,13 +1205,6 @@ class PlaidClient {
1059
1205
  * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1060
1206
  */
1061
1207
  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
1208
  listAll(this, `/api/v1/users/${userId}/data`, {
1070
1209
  pageSize,
1071
1210
  query: { prefix, pattern, "include-values": includeValues },
@@ -1113,15 +1252,13 @@ class PlaidClient {
1113
1252
  * @param {string} key
1114
1253
  */
1115
1254
  get: (userId, key) =>
1116
- // bypassBatch for the same reason as list() above.
1117
1255
  this._request(
1118
1256
  "GET",
1119
1257
  `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1120
- { bypassBatch: true },
1121
1258
  ),
1122
1259
  /**
1123
1260
  * Create or replace one private data entry. `value` is any JSON (up to
1124
- * 1 MB), stored as sent. Not audited, not batchable.
1261
+ * 1 MB). Not audited, not batchable.
1125
1262
  *
1126
1263
  * The server stores it verbatim, but this client recases object keys on
1127
1264
  * the way out and back like any other body (`myKey` <-> `my-key`), so a
@@ -1189,7 +1326,7 @@ class PlaidClient {
1189
1326
  * credential and is shown ONLY here — store it immediately. API tokens
1190
1327
  * do not expire and survive password changes / logout; revoke to kill.
1191
1328
  * @param {string} userId - The user ID who will own the token
1192
- * @param {string} name - A human label, e.g. "Stanza Parser"
1329
+ * @param {string} name - A human label, e.g. "Stanza parser"
1193
1330
  * @returns {Promise<{id: string, name: string, token: string}>}
1194
1331
  */
1195
1332
  create: (userId, name, auditMessage) =>
@@ -1318,8 +1455,7 @@ class PlaidClient {
1318
1455
  * walks the media directory, so open it, do not poll it. Admin only.
1319
1456
  * @returns {Promise<{version: string, jvm: object, database: object, media: object, backup: object, settings: object}>}
1320
1457
  */
1321
- server: () =>
1322
- this._request("GET", "/api/v1/admin/server", { bypassBatch: true }),
1458
+ server: () => this._request("GET", "/api/v1/admin/server"),
1323
1459
  /**
1324
1460
  * Take a database backup right now, outside the nightly schedule.
1325
1461
  * Resolves to the backup block with `ok` reporting whether the snapshot
@@ -1327,20 +1463,17 @@ class PlaidClient {
1327
1463
  * people are working. Admin only.
1328
1464
  * @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
1329
1465
  *
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.
1466
+ * outOfBand, as every admin action on the server itself is: none of them
1467
+ * writes project data (see the note at the top of http.js).
1334
1468
  */
1335
1469
  backup: () =>
1336
- this._request("POST", "/api/v1/admin/backup", { bypassBatch: true }),
1470
+ this._request("POST", "/api/v1/admin/backup", { outOfBand: true }),
1337
1471
  /**
1338
1472
  * Documents currently held by an editing lock, with who holds each and
1339
1473
  * when it expires on its own. Admin only.
1340
1474
  * @returns {Promise<{entries: Array<{documentId: string, userId: string, expiresAt: number}>}>}
1341
1475
  */
1342
- locks: () =>
1343
- this._request("GET", "/api/v1/admin/locks", { bypassBatch: true }),
1476
+ locks: () => this._request("GET", "/api/v1/admin/locks"),
1344
1477
  /**
1345
1478
  * Drop the lock on a document whoever holds it. Idempotent. For a client
1346
1479
  * that went away without releasing one. Admin only.
@@ -1348,7 +1481,7 @@ class PlaidClient {
1348
1481
  */
1349
1482
  releaseLock: (documentId) =>
1350
1483
  this._request("DELETE", `/api/v1/admin/locks/${documentId}`, {
1351
- bypassBatch: true,
1484
+ outOfBand: true,
1352
1485
  }),
1353
1486
  /**
1354
1487
  * Live login and invite rate-limit buckets: the address, the account
@@ -1356,10 +1489,7 @@ class PlaidClient {
1356
1489
  * it is currently blocking. Admin only.
1357
1490
  * @returns {Promise<{windowMs: number, logins: Array, ips: Array, invites: Array}>}
1358
1491
  */
1359
- rateLimits: () =>
1360
- this._request("GET", "/api/v1/admin/rate-limits", {
1361
- bypassBatch: true,
1362
- }),
1492
+ rateLimits: () => this._request("GET", "/api/v1/admin/rate-limits"),
1363
1493
  /**
1364
1494
  * Forget recorded rate-limit failures. With `ip`, clears that address,
1365
1495
  * narrowed to one account with `userId`. With neither, clears every
@@ -1370,7 +1500,7 @@ class PlaidClient {
1370
1500
  */
1371
1501
  clearRateLimits: ({ ip, userId } = {}) =>
1372
1502
  this._request("DELETE", "/api/v1/admin/rate-limits", {
1373
- bypassBatch: true,
1503
+ outOfBand: true,
1374
1504
  queryParams: { ip, "user-id": userId },
1375
1505
  }),
1376
1506
  /**
@@ -1394,7 +1524,6 @@ class PlaidClient {
1394
1524
  */
1395
1525
  logs: ({ limit, q, level, status, user, method } = {}) =>
1396
1526
  this._request("GET", "/api/v1/admin/logs", {
1397
- bypassBatch: true,
1398
1527
  queryParams: { limit, q, level, status, user, method },
1399
1528
  }),
1400
1529
  /**
@@ -1408,7 +1537,6 @@ class PlaidClient {
1408
1537
  */
1409
1538
  logFile: ({ lines } = {}) =>
1410
1539
  this._request("GET", "/api/v1/admin/logs/file", {
1411
- bypassBatch: true,
1412
1540
  queryParams: { lines },
1413
1541
  }),
1414
1542
  /**
@@ -1536,7 +1664,6 @@ class PlaidClient {
1536
1664
  ? `/api/v1/projects/${projectId}/audit/tally`
1537
1665
  : "/api/v1/audit/tally";
1538
1666
  const result = await this._request("GET", path, {
1539
- bypassBatch: true,
1540
1667
  queryParams: {
1541
1668
  "start-time": startTime,
1542
1669
  "end-time": endTime,
@@ -1615,13 +1742,9 @@ class PlaidClient {
1615
1742
  /**
1616
1743
  * Get a token layer by ID.
1617
1744
  * @param {string} tokenLayerId - The token layer ID
1618
- * @param {string} [asOf] - Temporal query timestamp
1619
1745
  */
1620
- get: (tokenLayerId, asOf) =>
1621
- this._request("GET", `/api/v1/token-layers/${tokenLayerId}`, {
1622
- bypassBatch: true,
1623
- queryParams: { "as-of": asOf },
1624
- }),
1746
+ get: (tokenLayerId) =>
1747
+ this._request("GET", `/api/v1/token-layers/${tokenLayerId}`),
1625
1748
  /**
1626
1749
  * Delete a token layer.
1627
1750
  * @param {string} tokenLayerId - The token layer ID
@@ -1646,38 +1769,34 @@ class PlaidClient {
1646
1769
  /**
1647
1770
  * Check the lock status of a document.
1648
1771
  * @param {string} documentId - The document ID
1649
- * @param {string} [asOf] - Temporal query timestamp
1650
1772
  */
1651
- checkLock: (documentId, asOf) =>
1652
- this._request("GET", `/api/v1/documents/${documentId}/lock`, {
1653
- bypassBatch: true,
1654
- queryParams: { "as-of": asOf },
1655
- }),
1773
+ checkLock: (documentId) =>
1774
+ this._request("GET", `/api/v1/documents/${documentId}/lock`),
1656
1775
  /**
1657
1776
  * Acquire or refresh a document lock.
1658
1777
  *
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.
1778
+ * outOfBand: the lock is a signal, not project data (see the note at
1779
+ * the top of http.js). Queued on a batch it would be taken only at
1780
+ * submit, after every write it was meant to guard, and until then answer
1781
+ * success to a caller that does not hold it and cannot see the 423
1782
+ * saying somebody else does.
1664
1783
  * @param {string} documentId - The document ID
1665
1784
  */
1666
1785
  acquireLock: (documentId, auditMessage) =>
1667
1786
  this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1668
1787
  auditMessage,
1669
- bypassBatch: true,
1788
+ outOfBand: true,
1670
1789
  }),
1671
1790
  /**
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.
1791
+ * Release a document lock. outOfBand, for the same reason as
1792
+ * acquireLock: queued, the lock would be held until the batch submits,
1793
+ * and not released at all if it aborts.
1675
1794
  * @param {string} documentId - The document ID
1676
1795
  */
1677
1796
  releaseLock: (documentId, auditMessage) =>
1678
1797
  this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
1679
1798
  auditMessage,
1680
- bypassBatch: true,
1799
+ outOfBand: true,
1681
1800
  }),
1682
1801
  /**
1683
1802
  * Hold this document's server-enforced lock for the length of `fn`,
@@ -1708,8 +1827,8 @@ class PlaidClient {
1708
1827
  * NOT re-entrant: nesting two `locked()` blocks on one document would
1709
1828
  * release on the inner exit and leave the outer unprotected. Lock at
1710
1829
  * 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.
1830
+ * CLIENT, like strict mode, so it stops every write the client makes
1831
+ * (on any batch of it too) and not only the ones this block makes.
1713
1832
  * @param {string} documentId - The document ID
1714
1833
  * @param {(lock: DocumentLock) => any} fn - The work to run while holding it
1715
1834
  * @param {object} [options] - `{ keepAlive }`: renew on a timer (default true)
@@ -1724,7 +1843,6 @@ class PlaidClient {
1724
1843
  */
1725
1844
  getMedia: (documentId) =>
1726
1845
  this._request("GET", `/api/v1/documents/${documentId}/media`, {
1727
- bypassBatch: true,
1728
1846
  binaryResponse: true,
1729
1847
  }),
1730
1848
  /**
@@ -1749,8 +1867,8 @@ class PlaidClient {
1749
1867
  * Delete media file for a document
1750
1868
  * @param {string} documentId - The document ID
1751
1869
  */
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
1870
+ // The upload above is multipart and cannot be batched, but a DELETE
1871
+ // carries no blob, so the batch transport takes it. It is a write
1754
1872
  // of the document's own data and queues like any other. Note that the
1755
1873
  // file removal happens outside the server's transaction, so a batch that
1756
1874
  // aborts after this op does not bring the file back.
@@ -1767,7 +1885,6 @@ class PlaidClient {
1767
1885
  this._request("PUT", `/api/v1/documents/${documentId}/metadata`, {
1768
1886
  auditMessage,
1769
1887
  rawBody: body,
1770
- skipResponseTransform: true,
1771
1888
  }),
1772
1889
  /**
1773
1890
  * Remove all metadata from a document.
@@ -1776,18 +1893,16 @@ class PlaidClient {
1776
1893
  deleteMetadata: (documentId, auditMessage) =>
1777
1894
  this._request("DELETE", `/api/v1/documents/${documentId}/metadata`, {
1778
1895
  auditMessage,
1779
- skipResponseTransform: true,
1780
1896
  }),
1781
1897
  /**
1782
- * Patch (shallow-merge) metadata for a document. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
1898
+ * Edit metadata for a document with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
1783
1899
  * @param {string} documentId - The document ID
1784
- * @param {any} body - The metadata patch
1900
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
1785
1901
  */
1786
1902
  patchMetadata: (documentId, body, auditMessage) =>
1787
1903
  this._request("PATCH", `/api/v1/documents/${documentId}/metadata`, {
1788
1904
  auditMessage,
1789
1905
  rawBody: body,
1790
- skipResponseTransform: true,
1791
1906
  }),
1792
1907
  /**
1793
1908
  * Get audit log for a document. Transparently follows pagination cursors
@@ -1795,18 +1910,16 @@ class PlaidClient {
1795
1910
  * @param {string} documentId - The document ID
1796
1911
  * @param {string} [startTime] - Start of time range
1797
1912
  * @param {string} [endTime] - End of time range
1798
- * @param {string} [asOf] - Temporal query timestamp
1799
1913
  * @param {string[]|string} [opTypes] - Only return operations of these
1800
1914
  * types, spelled as in an entry's `op/type` (e.g.
1801
1915
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1802
1916
  * one of its operations matches, carrying only the ones that did.
1803
1917
  */
1804
- audit: (documentId, startTime, endTime, asOf, opTypes) =>
1918
+ audit: (documentId, startTime, endTime, opTypes) =>
1805
1919
  listAll(this, `/api/v1/documents/${documentId}/audit`, {
1806
1920
  query: {
1807
1921
  "start-time": startTime,
1808
1922
  "end-time": endTime,
1809
- "as-of": asOf,
1810
1923
  "op-types": opTypesParam(opTypes),
1811
1924
  },
1812
1925
  }),
@@ -1814,6 +1927,7 @@ class PlaidClient {
1814
1927
  * One page of the same log, newest-first with `order: "desc"`. Use this
1815
1928
  * rather than audit() wherever the caller wants the recent end of a log
1816
1929
  * that may be long: audit() walks every page before it resolves.
1930
+ * @param {string} documentId - The document ID
1817
1931
  * @param {object} [opts]
1818
1932
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
1819
1933
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
@@ -1822,7 +1936,7 @@ class PlaidClient {
1822
1936
  */
1823
1937
  auditPage: (
1824
1938
  documentId,
1825
- { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
1939
+ { startTime, endTime, opTypes, order, limit, cursor } = {},
1826
1940
  ) =>
1827
1941
  listPage(this, `/api/v1/documents/${documentId}/audit`, {
1828
1942
  limit,
@@ -1830,7 +1944,6 @@ class PlaidClient {
1830
1944
  query: {
1831
1945
  "start-time": startTime,
1832
1946
  "end-time": endTime,
1833
- "as-of": asOf,
1834
1947
  "op-types": opTypesParam(opTypes),
1835
1948
  order,
1836
1949
  },
@@ -1874,7 +1987,6 @@ class PlaidClient {
1874
1987
  */
1875
1988
  get: (documentId, includeBody, asOf, layers) =>
1876
1989
  this._request("GET", `/api/v1/documents/${documentId}`, {
1877
- bypassBatch: true,
1878
1990
  queryParams: {
1879
1991
  "include-body": includeBody,
1880
1992
  "as-of": asOf,
@@ -2019,18 +2131,16 @@ class PlaidClient {
2019
2131
  * @param {string} projectId - The project ID
2020
2132
  * @param {string} [startTime] - Start of time range
2021
2133
  * @param {string} [endTime] - End of time range
2022
- * @param {string} [asOf] - Temporal query timestamp
2023
2134
  * @param {string[]|string} [opTypes] - Only return operations of these
2024
2135
  * types, spelled as in an entry's `op/type` (e.g.
2025
2136
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
2026
2137
  * one of its operations matches, carrying only the ones that did.
2027
2138
  */
2028
- audit: (projectId, startTime, endTime, asOf, opTypes) =>
2139
+ audit: (projectId, startTime, endTime, opTypes) =>
2029
2140
  listAll(this, `/api/v1/projects/${projectId}/audit`, {
2030
2141
  query: {
2031
2142
  "start-time": startTime,
2032
2143
  "end-time": endTime,
2033
- "as-of": asOf,
2034
2144
  "op-types": opTypesParam(opTypes),
2035
2145
  },
2036
2146
  }),
@@ -2038,6 +2148,7 @@ class PlaidClient {
2038
2148
  * One page of the same log, newest-first with `order: "desc"`. Use this
2039
2149
  * rather than audit() wherever the caller wants the recent end of a log
2040
2150
  * that may be long: audit() walks every page before it resolves.
2151
+ * @param {string} projectId - The project ID
2041
2152
  * @param {object} [opts]
2042
2153
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2043
2154
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
@@ -2046,7 +2157,7 @@ class PlaidClient {
2046
2157
  */
2047
2158
  auditPage: (
2048
2159
  projectId,
2049
- { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
2160
+ { startTime, endTime, opTypes, order, limit, cursor } = {},
2050
2161
  ) =>
2051
2162
  listPage(this, `/api/v1/projects/${projectId}/audit`, {
2052
2163
  limit,
@@ -2054,7 +2165,6 @@ class PlaidClient {
2054
2165
  query: {
2055
2166
  "start-time": startTime,
2056
2167
  "end-time": endTime,
2057
- "as-of": asOf,
2058
2168
  "op-types": opTypesParam(opTypes),
2059
2169
  order,
2060
2170
  },
@@ -2071,7 +2181,6 @@ class PlaidClient {
2071
2181
  */
2072
2182
  myLastEdits: (projectId) =>
2073
2183
  this._request("GET", `/api/v1/projects/${projectId}/audit/last-edits`, {
2074
- bypassBatch: true,
2075
2184
  skipResponseTransform: true,
2076
2185
  }),
2077
2186
  /**
@@ -2096,13 +2205,9 @@ class PlaidClient {
2096
2205
  * Get a project by ID. To fetch the project's documents, use
2097
2206
  * listDocuments(id) — the include-documents flag has been removed.
2098
2207
  * @param {string} id - The resource ID
2099
- * @param {string} [asOf] - Temporal query timestamp
2100
2208
  */
2101
- get: (id, asOf) =>
2102
- this._request("GET", `/api/v1/projects/${id}`, {
2103
- bypassBatch: true,
2104
- queryParams: { "as-of": asOf },
2105
- }),
2209
+ get: (id) =>
2210
+ this._request("GET", `/api/v1/projects/${id}`),
2106
2211
  /**
2107
2212
  * List all documents in a project. Transparently follows pagination
2108
2213
  * cursors and returns the full flat array.
@@ -2139,17 +2244,15 @@ class PlaidClient {
2139
2244
  iterDocuments: (id, { pageSize } = {}) =>
2140
2245
  iterPages(this, `/api/v1/projects/${id}/documents`, { pageSize }),
2141
2246
  /**
2142
- * Delete a project and everything in it. This is irrecoverable.
2143
- *
2144
- * For a large project this can take a long time, so the client's
2145
- * per-request timeout is DISABLED by default. Pass `{ timeout }` to
2146
- * impose a finite bound.
2247
+ * Delete a project and everything in it. This is irrecoverable. The
2248
+ * project is gone when this returns, and what it holds is removed on
2249
+ * the server afterwards.
2147
2250
  * @param {string} id - The resource ID
2148
2251
  * @param {string} [auditMessage] - Custom audit-log message
2149
2252
  * @param {object} [options]
2150
- * @param {number} [options.timeout=0] - Per-request timeout in ms (0/null disables)
2253
+ * @param {number} [options.timeout] - Per-request timeout in ms, the client's own by default (0/null disables)
2151
2254
  */
2152
- delete: (id, auditMessage, { timeout = 0 } = {}) =>
2255
+ delete: (id, auditMessage, { timeout } = {}) =>
2153
2256
  this._request("DELETE", `/api/v1/projects/${id}`, {
2154
2257
  auditMessage,
2155
2258
  timeout,
@@ -2167,35 +2270,29 @@ class PlaidClient {
2167
2270
  /**
2168
2271
  * List all projects accessible to user. Transparently follows pagination
2169
2272
  * cursors and returns the full flat array.
2170
- * @param {string} [asOf] - Temporal query timestamp
2171
2273
  */
2172
- list: (asOf) =>
2173
- listAll(this, "/api/v1/projects", { query: { "as-of": asOf } }),
2274
+ list: () => listAll(this, "/api/v1/projects"),
2174
2275
  /**
2175
2276
  * Fetch a single page of projects.
2176
2277
  * @param {object} [opts]
2177
2278
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2178
2279
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2179
- * @param {string} [opts.asOf] - Temporal query timestamp
2180
2280
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2181
2281
  */
2182
- listPage: ({ limit, cursor, asOf } = {}) =>
2282
+ listPage: ({ limit, cursor } = {}) =>
2183
2283
  listPage(this, "/api/v1/projects", {
2184
2284
  limit,
2185
2285
  cursor,
2186
- query: { "as-of": asOf },
2187
2286
  }),
2188
2287
  /**
2189
2288
  * Async-iterate projects page by page; yields each page's entries array.
2190
2289
  * @param {object} [opts]
2191
2290
  * @param {number} [opts.pageSize] - Per-request page size
2192
- * @param {string} [opts.asOf] - Temporal query timestamp
2193
2291
  * @returns {AsyncGenerator<Array>}
2194
2292
  */
2195
- iterPages: ({ pageSize, asOf } = {}) =>
2293
+ iterPages: ({ pageSize } = {}) =>
2196
2294
  iterPages(this, "/api/v1/projects", {
2197
2295
  pageSize,
2198
- query: { "as-of": asOf },
2199
2296
  }),
2200
2297
  /**
2201
2298
  * Create a new project. Note: this also registers the user as a maintainer.
@@ -2243,13 +2340,9 @@ class PlaidClient {
2243
2340
  /**
2244
2341
  * Get a text layer by ID.
2245
2342
  * @param {string} textLayerId - The text layer ID
2246
- * @param {string} [asOf] - Temporal query timestamp
2247
2343
  */
2248
- get: (textLayerId, asOf) =>
2249
- this._request("GET", `/api/v1/text-layers/${textLayerId}`, {
2250
- bypassBatch: true,
2251
- queryParams: { "as-of": asOf },
2252
- }),
2344
+ get: (textLayerId) =>
2345
+ this._request("GET", `/api/v1/text-layers/${textLayerId}`),
2253
2346
  /**
2254
2347
  * Delete a text layer.
2255
2348
  * @param {string} textLayerId - The text layer ID
@@ -2300,7 +2393,6 @@ class PlaidClient {
2300
2393
  this._request("PUT", `/api/v1/vocab-items/${id}/metadata`, {
2301
2394
  auditMessage,
2302
2395
  rawBody: body,
2303
- skipResponseTransform: true,
2304
2396
  }),
2305
2397
  /**
2306
2398
  * Remove all metadata from a vocab item.
@@ -2309,18 +2401,16 @@ class PlaidClient {
2309
2401
  deleteMetadata: (id, auditMessage) =>
2310
2402
  this._request("DELETE", `/api/v1/vocab-items/${id}/metadata`, {
2311
2403
  auditMessage,
2312
- skipResponseTransform: true,
2313
2404
  }),
2314
2405
  /**
2315
- * Patch (shallow-merge) metadata for a vocab item. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
2406
+ * Edit metadata for a vocab item with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
2316
2407
  * @param {string} id - The resource ID
2317
- * @param {any} body - The metadata patch
2408
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
2318
2409
  */
2319
2410
  patchMetadata: (id, body, auditMessage) =>
2320
2411
  this._request("PATCH", `/api/v1/vocab-items/${id}/metadata`, {
2321
2412
  auditMessage,
2322
2413
  rawBody: body,
2323
- skipResponseTransform: true,
2324
2414
  }),
2325
2415
  /**
2326
2416
  * Create a new vocab item
@@ -2343,9 +2433,23 @@ class PlaidClient {
2343
2433
  auditMessage,
2344
2434
  body,
2345
2435
  }),
2436
+ /**
2437
+ * Update many vocab items in a single operation: set forms and/or patch
2438
+ * metadata.
2439
+ * @param {Array<{id: string, form?: string, metadata?: Array<object>}>} body - `form` is set only when the key is present; `metadata` is a list of metadata ops, as for patchMetadata. 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.
2440
+ * @returns {Promise<{count: number}>} How many vocab items were updated.
2441
+ */
2442
+ bulkUpdate: (body, auditMessage) =>
2443
+ this._request("PATCH", "/api/v1/vocab-items/bulk", {
2444
+ auditMessage,
2445
+ body,
2446
+ }),
2346
2447
  /**
2347
2448
  * Delete multiple vocab items in a single operation. Each item's
2348
2449
  * descendant vocab links are deleted too. Provide an array of IDs.
2450
+ *
2451
+ * Every document holding one of those links has its version bumped, and
2452
+ * a strict-mode client picks up their new versions from the response.
2349
2453
  * @param {string[]} body - The vocab item IDs to delete
2350
2454
  */
2351
2455
  bulkDelete: (body, auditMessage) =>
@@ -2356,21 +2460,22 @@ class PlaidClient {
2356
2460
  /**
2357
2461
  * Get a vocab item by ID
2358
2462
  * @param {string} id - The resource ID
2359
- * @param {string} [asOf] - Temporal query timestamp
2360
2463
  */
2361
- get: (id, asOf) =>
2362
- this._request("GET", `/api/v1/vocab-items/${id}`, {
2363
- bypassBatch: true,
2364
- queryParams: { "as-of": asOf },
2365
- }),
2464
+ get: (id) =>
2465
+ this._request("GET", `/api/v1/vocab-items/${id}`),
2366
2466
  /**
2367
- * Delete a vocab item
2467
+ * Delete a vocab item, and every link to it. Every document holding one
2468
+ * of those links has its version bumped, and a strict-mode client picks
2469
+ * up their new versions from the response.
2368
2470
  * @param {string} id - The resource ID
2369
2471
  */
2370
2472
  delete: (id, auditMessage) =>
2371
2473
  this._request("DELETE", `/api/v1/vocab-items/${id}`, { auditMessage }),
2372
2474
  /**
2373
- * Update a vocab item's form
2475
+ * Update a vocab item's form. A document read carries the entry's form on
2476
+ * every link to it, so a rename restates those documents: each has its
2477
+ * version bumped, and a strict-mode client picks up their new versions
2478
+ * from the response.
2374
2479
  * @param {string} id - The resource ID
2375
2480
  * @param {string} form - The vocab item form
2376
2481
  */
@@ -2437,13 +2542,9 @@ class PlaidClient {
2437
2542
  /**
2438
2543
  * Get a relation layer by ID.
2439
2544
  * @param {string} relationLayerId - The relation layer ID
2440
- * @param {string} [asOf] - Temporal query timestamp
2441
2545
  */
2442
- get: (relationLayerId, asOf) =>
2443
- this._request("GET", `/api/v1/relation-layers/${relationLayerId}`, {
2444
- bypassBatch: true,
2445
- queryParams: { "as-of": asOf },
2446
- }),
2546
+ get: (relationLayerId) =>
2547
+ this._request("GET", `/api/v1/relation-layers/${relationLayerId}`),
2447
2548
  /**
2448
2549
  * Delete a relation layer.
2449
2550
  * @param {string} relationLayerId - The relation layer ID
@@ -2506,13 +2607,9 @@ class PlaidClient {
2506
2607
  /**
2507
2608
  * Get a token.
2508
2609
  * @param {string} tokenId - The token ID
2509
- * @param {string} [asOf] - Temporal query timestamp
2510
2610
  */
2511
- get: (tokenId, asOf) =>
2512
- this._request("GET", `/api/v1/tokens/${tokenId}`, {
2513
- bypassBatch: true,
2514
- queryParams: { "as-of": asOf },
2515
- }),
2611
+ get: (tokenId) =>
2612
+ this._request("GET", `/api/v1/tokens/${tokenId}`),
2516
2613
  /**
2517
2614
  * Delete a token and remove it from any spans. If this causes a span to
2518
2615
  * have no remaining tokens, the span will also be deleted.
@@ -2550,7 +2647,7 @@ class PlaidClient {
2550
2647
  this._request("DELETE", "/api/v1/tokens/bulk", { auditMessage, body }),
2551
2648
  /**
2552
2649
  * 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.
2650
+ * @param {Array} body - Objects of the shape `{id, metadata}`. `metadata` is a list of metadata ops, as for patchMetadata. 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` (past fifty documents, only their number, in `X-Document-Versions-Omitted`). A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
2554
2651
  * @returns {Promise<{count: number}>} How many tokens were updated.
2555
2652
  */
2556
2653
  bulkUpdate: (body, auditMessage) =>
@@ -2604,7 +2701,6 @@ class PlaidClient {
2604
2701
  this._request("PUT", `/api/v1/tokens/${tokenId}/metadata`, {
2605
2702
  auditMessage,
2606
2703
  rawBody: body,
2607
- skipResponseTransform: true,
2608
2704
  }),
2609
2705
  /**
2610
2706
  * Remove all metadata from a token.
@@ -2613,18 +2709,16 @@ class PlaidClient {
2613
2709
  deleteMetadata: (tokenId, auditMessage) =>
2614
2710
  this._request("DELETE", `/api/v1/tokens/${tokenId}/metadata`, {
2615
2711
  auditMessage,
2616
- skipResponseTransform: true,
2617
2712
  }),
2618
2713
  /**
2619
- * Patch (shallow-merge) metadata for a token. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
2714
+ * Edit metadata for a token with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
2620
2715
  * @param {string} tokenId - The token ID
2621
- * @param {any} body - The metadata patch
2716
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
2622
2717
  */
2623
2718
  patchMetadata: (tokenId, body, auditMessage) =>
2624
2719
  this._request("PATCH", `/api/v1/tokens/${tokenId}/metadata`, {
2625
2720
  auditMessage,
2626
2721
  rawBody: body,
2627
- skipResponseTransform: true,
2628
2722
  }),
2629
2723
  };
2630
2724
 
@@ -2648,21 +2742,24 @@ class PlaidClient {
2648
2742
  * this a key such as `case-marker` would reach listeners as
2649
2743
  * `caseMarker` in JS and `case_marker` in Python. `listen` restores it
2650
2744
  * verbatim on the way in.
2745
+ *
2746
+ * A message is not saved and writes nothing to History, so it never
2747
+ * joins an open operation (`withOperation`). Made on a batch it still
2748
+ * queues, so a message queued after the writes goes out after them.
2651
2749
  * @param {string} projectId - The UUID of the project to send to
2652
2750
  * @param {any} data - The message data to send
2653
2751
  * @returns {Promise<any>} Response from the send operation
2654
2752
  */
2655
- sendMessage: (projectId, data, auditMessage) =>
2753
+ sendMessage: (projectId, data) =>
2656
2754
  this._request("POST", `/api/v1/projects/${projectId}/message`, {
2657
- auditMessage,
2755
+ noOperation: true,
2658
2756
  rawBody: { body: data },
2659
2757
  }),
2660
2758
 
2661
2759
  /**
2662
2760
  * Discover the services seen on a project (synchronous GET). Currently
2663
2761
  * 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.
2762
+ * carry `online: false` plus a `lastSeenAt` stamp.
2666
2763
  * @param {string} projectId - The UUID of the project to query
2667
2764
  * @returns {Promise<Array>} Array of discovered service information
2668
2765
  */
@@ -2688,7 +2785,9 @@ class PlaidClient {
2688
2785
  * @param {function} onServiceRequest - Callback (data, responseHelper)
2689
2786
  * @param {Object} [extras] - Optional additional service metadata
2690
2787
  * @param {function} [onStatus] - Optional callback (event, projectId, detail) for
2691
- * connection transitions: 'registered', 'reconnected', 'disconnected'
2788
+ * connection transitions: 'registered', 'reconnected', 'disconnected',
2789
+ * and 'stopped' when the server refuses the channel for good, which
2790
+ * ends the registration
2692
2791
  * @returns {Object} Service registration with .stop(), .isRunning(), .isConnected()
2693
2792
  */
2694
2793
  serve: (projectId, serviceInfo, onServiceRequest, extras, onStatus) =>
@@ -2710,7 +2809,7 @@ class PlaidClient {
2710
2809
  * @param {number} [timeout=10000] - Timeout in ms
2711
2810
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
2712
2811
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
2713
- * @param {Object} [opts] - {requestId, onAccepted}
2812
+ * @param {Object} [opts] - {requestId, onAccepted, projectIds}
2714
2813
  * @returns {Promise<any>} The service's result
2715
2814
  */
2716
2815
  requestService: (
@@ -2808,21 +2907,15 @@ class PlaidClient {
2808
2907
  * @param {Object} body - The query AST ({find, where, scope?, limit?, orderBy?,
2809
2908
  * return?, bindings?}).
2810
2909
  * @returns {Promise<Object>} For 'ids'/'entities': {columns, results, count, truncated}.
2811
- * For 'count': {return: 'count', count}. Entity cells are full entity objects
2812
- * (same shape as the GET endpoints).
2910
+ * For 'count': {return: 'count', count, truncated}, where `truncated` says
2911
+ * the count stopped at the server's cap. Entity cells are full entity
2912
+ * objects (same shape as the GET endpoints).
2813
2913
  */
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.
2820
- this.query = (body, auditMessage) =>
2821
- this._request("POST", "/api/v1/query", {
2822
- auditMessage,
2823
- body,
2824
- bypassBatch: true,
2825
- });
2914
+ // outOfBand: a query is a read that travels as a POST (see the note at
2915
+ // the top of http.js). Made on a batch it goes over the wire like any
2916
+ // read, and it never joins a logical operation.
2917
+ this.query = (body) =>
2918
+ this._request("POST", "/api/v1/query", { body, outOfBand: true });
2826
2919
 
2827
2920
  // Logical-operation groups (audit-log grouping). There is no create: a
2828
2921
  // group row is made lazily by the first write carrying `?group-id=`
@@ -2832,10 +2925,7 @@ class PlaidClient {
2832
2925
  * Get a logical-operation group (its label + creator).
2833
2926
  * @param {string} id - The group id
2834
2927
  */
2835
- get: (id) =>
2836
- this._request("GET", `/api/v1/operation-groups/${id}`, {
2837
- bypassBatch: true,
2838
- }),
2928
+ get: (id) => this._request("GET", `/api/v1/operation-groups/${id}`),
2839
2929
  /**
2840
2930
  * Relabel a logical-operation group after the fact. Owner or admin only.
2841
2931
  * @param {string} id - The group id
@@ -2876,8 +2966,7 @@ class PlaidClient {
2876
2966
  * Read one comment.
2877
2967
  * @param {string} id - The comment id
2878
2968
  */
2879
- get: (id) =>
2880
- this._request("GET", `/api/v1/comments/${id}`, { bypassBatch: true }),
2969
+ get: (id) => this._request("GET", `/api/v1/comments/${id}`),
2881
2970
  /**
2882
2971
  * Edit a comment's body. Only the comment's AUTHOR may do this - not
2883
2972
  * maintainers, not admins. Sets `edited` on the comment.
@@ -2969,7 +3058,6 @@ class PlaidClient {
2969
3058
  */
2970
3059
  counts: (projectId, { documentId, entityType, entityId } = {}) =>
2971
3060
  this._request("GET", `/api/v1/projects/${projectId}/comments/counts`, {
2972
- bypassBatch: true,
2973
3061
  queryParams: {
2974
3062
  "document-id": documentId,
2975
3063
  "entity-type": entityType,
@@ -3004,6 +3092,20 @@ class PlaidClient {
3004
3092
  cursor,
3005
3093
  query: { "entity-id": entityId },
3006
3094
  }),
3095
+ /**
3096
+ * Async-iterate a vocabulary's comments page by page, oldest first;
3097
+ * yields each page's entries array.
3098
+ * @param {string} vocabId - The vocab layer to read
3099
+ * @param {object} [opts]
3100
+ * @param {string} [opts.entityId] - One entry's thread
3101
+ * @param {number} [opts.pageSize] - Per-request page size
3102
+ * @returns {AsyncGenerator<Array>}
3103
+ */
3104
+ iterInVocabPages: (vocabId, { entityId, pageSize } = {}) =>
3105
+ iterPages(this, `/api/v1/vocab-layers/${vocabId}/comments`, {
3106
+ pageSize,
3107
+ query: { "entity-id": entityId },
3108
+ }),
3007
3109
  /**
3008
3110
  * Comment counts per entry of a vocabulary, as an `{entityId: n}` map.
3009
3111
  * The response is NOT key-transformed: its keys are entry ids.
@@ -3017,12 +3119,111 @@ class PlaidClient {
3017
3119
  "GET",
3018
3120
  `/api/v1/vocab-layers/${vocabId}/comments/counts`,
3019
3121
  {
3020
- bypassBatch: true,
3021
3122
  queryParams: { "entity-id": entityId },
3022
3123
  skipResponseTransform: true,
3023
3124
  },
3024
3125
  ),
3025
3126
  };
3127
+
3128
+ this.guidelines = {
3129
+ /**
3130
+ * Create a guideline in a project. `title` is the handle an assistant
3131
+ * asks for one by, and the only thing a person has to keep current. It is
3132
+ * NOT required to be unique, so a title already in use is written like
3133
+ * any other: warn about it, do not refuse it. A `pinned` guideline is one
3134
+ * the assistant is given in full on every turn.
3135
+ * @param {string} projectId - The project the guideline belongs to
3136
+ * @param {string} title - The handle an assistant asks for one by (1..100 characters)
3137
+ * @param {object} [opts]
3138
+ * @param {string} [opts.body] - The Markdown text (up to 20000 characters; may be empty)
3139
+ * @param {boolean} [opts.pinned] - Send this one to the assistant in full on every turn
3140
+ */
3141
+ create: (projectId, title, { body, pinned } = {}, auditMessage) =>
3142
+ this._request("POST", `/api/v1/projects/${projectId}/guidelines`, {
3143
+ auditMessage,
3144
+ body: bodyOf({ title, body, pinned }),
3145
+ }),
3146
+ /**
3147
+ * Read one guideline, Markdown body included.
3148
+ * @param {string} id - The guideline id
3149
+ */
3150
+ get: (id) => this._request("GET", `/api/v1/guidelines/${id}`),
3151
+ /**
3152
+ * Update a guideline. Every field is optional and an omitted one is
3153
+ * left alone, so an edit to the body need not restate the title.
3154
+ *
3155
+ * Pass `expectedUpdatedAt` (the `updatedAt` you last read) when a person
3156
+ * has been editing prose: the write then fails with 409 rather than
3157
+ * overwriting somebody who saved in between. Leave it off for a pin
3158
+ * toggle or a script, which have nothing of anyone's to lose.
3159
+ * @param {string} id - The guideline id
3160
+ * @param {object} [changes]
3161
+ * @param {string} [changes.title] - The new handle
3162
+ * @param {string} [changes.body] - The new Markdown text
3163
+ * @param {boolean} [changes.pinned] - Whether the assistant always gets it in full
3164
+ * @param {string} [changes.expectedUpdatedAt] - Write only if this is still the stored updatedAt
3165
+ */
3166
+ update: (
3167
+ id,
3168
+ { title, body, pinned, expectedUpdatedAt } = {},
3169
+ auditMessage,
3170
+ ) =>
3171
+ this._request("PATCH", `/api/v1/guidelines/${id}`, {
3172
+ auditMessage,
3173
+ queryParams: { "updated-at": expectedUpdatedAt },
3174
+ body: bodyOf({ title, body, pinned }),
3175
+ }),
3176
+ /**
3177
+ * Delete a guideline.
3178
+ * @param {string} id - The guideline id
3179
+ */
3180
+ delete: (id, auditMessage) =>
3181
+ this._request("DELETE", `/api/v1/guidelines/${id}`, { auditMessage }),
3182
+ /**
3183
+ * List a project's guidelines, by title. Transparently follows
3184
+ * pagination cursors and returns the full flat array.
3185
+ *
3186
+ * Without `includeBodies` each entry carries `bodyChars`, the length of
3187
+ * its body, so a caller can budget before fetching any. Pinned
3188
+ * guidelines are NOT sorted first: `pinned` is on every entry and
3189
+ * grouping is the caller's.
3190
+ * @param {string} projectId - The project to read
3191
+ * @param {object} [opts]
3192
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3193
+ */
3194
+ list: (projectId, { includeBodies } = {}) =>
3195
+ listAll(this, `/api/v1/projects/${projectId}/guidelines`, {
3196
+ query: { "include-bodies": includeBodies },
3197
+ }),
3198
+ /**
3199
+ * Fetch a single page of a project's guidelines.
3200
+ * @param {string} projectId - The project to read
3201
+ * @param {object} [opts]
3202
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
3203
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
3204
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3205
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
3206
+ */
3207
+ listPage: (projectId, { limit, cursor, includeBodies } = {}) =>
3208
+ listPage(this, `/api/v1/projects/${projectId}/guidelines`, {
3209
+ limit,
3210
+ cursor,
3211
+ query: { "include-bodies": includeBodies },
3212
+ }),
3213
+ /**
3214
+ * Async-iterate a project's guidelines page by page; yields each page's entries array.
3215
+ * @param {string} projectId - The project to read
3216
+ * @param {object} [opts]
3217
+ * @param {number} [opts.pageSize] - Per-request page size
3218
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3219
+ * @returns {AsyncGenerator<Array>}
3220
+ */
3221
+ iterPages: (projectId, { pageSize, includeBodies } = {}) =>
3222
+ iterPages(this, `/api/v1/projects/${projectId}/guidelines`, {
3223
+ pageSize,
3224
+ query: { "include-bodies": includeBodies },
3225
+ }),
3226
+ };
3026
3227
  }
3027
3228
 
3028
3229
  // --- Core methods ---
@@ -3067,8 +3268,8 @@ class PlaidClient {
3067
3268
  * "Every write" is the writes of project data. Reads never join, and neither
3068
3269
  * do the out-of-band signals shaped like a write (a document lock taken or
3069
3270
  * 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.
3271
+ * control), and neither does a broadcast message (`messages.sendMessage`):
3272
+ * none of them is audited, so there would be nothing under the label.
3072
3273
  *
3073
3274
  * @param {string} message - Human label for the operation.
3074
3275
  * @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).
@@ -3124,7 +3325,7 @@ class PlaidClient {
3124
3325
  * callback to refine the label once the outcome is known.
3125
3326
  *
3126
3327
  * await client.withOperation('Merge morphemes', async (setMessage) => {
3127
- * await client.batched(() => { ... });
3328
+ * await client.batched(async (b) => { ... });
3128
3329
  * setMessage(`Merged ${n} morphemes`);
3129
3330
  * });
3130
3331
  *
@@ -3146,58 +3347,38 @@ class PlaidClient {
3146
3347
  }
3147
3348
 
3148
3349
  /**
3149
- * Begin a batch of operations. Subsequent WRITES are queued; reads and
3150
- * out-of-band signals still go over the wire (see `batched`).
3350
+ * Open a batch: a view of this client with the same bundles, on which every
3351
+ * write of project data queues instead of going out. `submit()` sends the
3352
+ * queued operations as ONE atomic request (larger than the server's cap,
3353
+ * as consecutive requests with the results concatenated in queue order,
3354
+ * each atomic on its own, so a failure in a later one leaves the earlier
3355
+ * ones committed, and the error it throws carries `committed`, the count
3356
+ * saved, and `committedResults`, their results) and
3357
+ * resolves to one result per operation; `abort()` drops them. A call made
3358
+ * on the client itself is never touched by an open batch, and a read or an
3359
+ * out-of-band signal made on the batch goes over the wire now (see the note
3360
+ * at the top of `http.js`).
3361
+ *
3362
+ * const b = client.batch();
3363
+ * b.tokens.bulkCreate(sentenceOps);
3364
+ * b.tokens.bulkCreate(wordOps);
3365
+ * const [sentRes, wordRes] = await b.submit();
3366
+ *
3367
+ * Server-side a batch runs sequentially in one transaction: a child op sees
3368
+ * parents created earlier in the same batch, and any op's failure rolls the
3369
+ * whole batch back. A batch is not nestable. Prefer `batched()`, which
3370
+ * submits or aborts for you.
3371
+ * @returns {PlaidClient} the batch view
3151
3372
  */
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;
3373
+ batch() {
3374
+ return openBatch(this);
3158
3375
  }
3159
3376
 
3160
3377
  /**
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
3378
+ * POST one batch request (at most MAX_BATCH_OPS operations). Resolves to
3379
+ * one `{ status, headers, body }` per operation, with only the body
3380
+ * recased: the headers keep the server's spelling.
3164
3381
  */
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
- }
3198
- }
3199
-
3200
- /** POST one batch request (at most MAX_BATCH_OPS operations); resolves to its transformed results. */
3201
3382
  async _postBatch(url, body) {
3202
3383
  const fetchOptions = {
3203
3384
  method: "POST",
@@ -3222,6 +3403,22 @@ class PlaidClient {
3222
3403
  throw makeHttpError(res, await parseErrorBody(res), url, "POST");
3223
3404
  }
3224
3405
  return res;
3406
+ }).catch((error) => {
3407
+ // 401 means the token is missing, expired or invalid: fire the app's
3408
+ // auth-error handler once, exactly as makeRequest does.
3409
+ if (
3410
+ error?.status === 401 &&
3411
+ typeof this.onAuthError === "function" &&
3412
+ !this._authErrorFired
3413
+ ) {
3414
+ this._authErrorFired = true;
3415
+ try {
3416
+ this.onAuthError(error);
3417
+ } catch (_) {
3418
+ /* handler must not mask the original error */
3419
+ }
3420
+ }
3421
+ throw error;
3225
3422
  });
3226
3423
 
3227
3424
  const results = await response.json();
@@ -3249,80 +3446,44 @@ class PlaidClient {
3249
3446
  }
3250
3447
  }
3251
3448
 
3252
- return results.map((result) => transformResponse(result));
3449
+ return results.map((result) => ({
3450
+ ...result,
3451
+ body: transformResponse(result.body),
3452
+ }));
3253
3453
  } catch (error) {
3254
3454
  if (error.status) throw error;
3255
3455
  throw makeNetworkError(error, url, "POST");
3256
3456
  }
3257
3457
  }
3258
3458
 
3259
- /** Abort the current batch without executing any operations. */
3260
- abortBatch() {
3261
- this.isBatching = false;
3262
- this.batchOperations = [];
3263
- }
3264
-
3265
3459
  /**
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).
3460
+ * Run `fn` with a batch, then submit all queued ops as `submit()` does (ONE
3461
+ * atomic request up to MAX_BATCH_OPS operations, consecutive requests past
3462
+ * it), or abort the batch if `fn` throws. `fn` receives the batch and makes its
3463
+ * writes on it; it must NOT call `submit()` itself. Resolves to the batch
3464
+ * results array (`[]` if `fn` queued nothing).
3279
3465
  *
3280
- * const [sentRes, wordRes] = await client.batched(async () => {
3281
- * client.tokens.bulkCreate(sentenceOps);
3282
- * client.tokens.bulkCreate(wordOps);
3466
+ * const [sentRes, wordRes] = await client.batched(async (b) => {
3467
+ * b.tokens.bulkCreate(sentenceOps);
3468
+ * b.tokens.bulkCreate(wordOps);
3283
3469
  * });
3284
3470
  *
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
3471
+ * A write made on `client` inside `fn` is not part of the batch: it goes
3472
+ * over the wire at once, as it would anywhere else. See `batch()`.
3473
+ * @param {(batch: PlaidClient) => (void | Promise<void>)} fn
3302
3474
  * @returns {Promise<Array>}
3303
3475
  */
3304
3476
  async batched(fn) {
3305
- this.beginBatch();
3477
+ const batch = this.batch();
3306
3478
  try {
3307
- await fn();
3479
+ await fn(batch);
3308
3480
  } 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();
3481
+ batch.abort();
3312
3482
  throw e;
3313
3483
  }
3314
- return this.submitBatch();
3484
+ return batch.submit();
3315
3485
  }
3316
3486
 
3317
- /**
3318
- * Authenticate and return a new client instance with token. This is the
3319
- * single auth entry point — there is no `client.login` resource.
3320
- * @param {string} baseUrl - The base URL for the API
3321
- * @param {string} userId - User ID for authentication
3322
- * @param {string} password - Password for authentication
3323
- * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
3324
- * @returns {Promise<PlaidClient>} - Authenticated client instance
3325
- */
3326
3487
  /**
3327
3488
  * Liveness, version and database size, with NO authentication and no client
3328
3489
  * instance — for a launcher or status page checking whether a server is up
@@ -3417,6 +3578,15 @@ class PlaidClient {
3417
3578
  };
3418
3579
  }
3419
3580
 
3581
+ /**
3582
+ * Authenticate and return a new client instance with token. This is the
3583
+ * single auth entry point — there is no `client.login` resource.
3584
+ * @param {string} baseUrl - The base URL for the API
3585
+ * @param {string} userId - User ID for authentication
3586
+ * @param {string} password - Password for authentication
3587
+ * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
3588
+ * @returns {Promise<PlaidClient>} - Authenticated client instance
3589
+ */
3420
3590
  static async login(baseUrl, userId, password, options = {}) {
3421
3591
  baseUrl = baseUrl.replace(/\/$/, "");
3422
3592
  const url = `${baseUrl}/api/v1/login`;
@@ -3516,7 +3686,6 @@ export {
3516
3686
  needsReview,
3517
3687
  verifyOnEdit,
3518
3688
  contributeOnEdit,
3519
- mergeMetadata,
3520
3689
  serviceSource,
3521
3690
  userSource,
3522
3691
  REVIEW_KEY,
@@ -3527,4 +3696,15 @@ export {
3527
3696
  withReviewedUser,
3528
3697
  writerPolicy,
3529
3698
  } from "./provenance.js";
3699
+ // Metadata ops: the body of a metadata PATCH and of a bulk update entry's
3700
+ // `metadata`. See ./metadataOps.js.
3701
+ export {
3702
+ metadataOps,
3703
+ applyMetadataOps,
3704
+ mergeMetadata,
3705
+ isReservedMetadataKey,
3706
+ } from "./metadataOps.js";
3707
+ // The ids a create or bulk create answered with, off the call or its batch
3708
+ // result. See ./created.js.
3709
+ export { createdId, createdIds } from "./created.js";
3530
3710
  export { MAX_BATCH_OPS };