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

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
@@ -6,6 +6,7 @@ import { transformRequest, transformResponse } from "./transforms.js";
6
6
  import {
7
7
  makeRequest,
8
8
  queueRequest,
9
+ restampDocumentVersion,
9
10
  extractDocumentVersions,
10
11
  parseErrorBody,
11
12
  makeHttpError,
@@ -16,6 +17,7 @@ import {
16
17
  DEFAULT_BATCH_TIMEOUT_MS,
17
18
  } from "./http.js";
18
19
  import { listAll, listPage, iterPages } from "./pagination.js";
20
+ import { recorderFor } from "./events.js";
19
21
  import { withDocumentLock } from "./documentLock.js";
20
22
  import { createSSEConnection } from "./sse.js";
21
23
  import {
@@ -128,6 +130,9 @@ function openBatch(client) {
128
130
  const batch = Object.create(client);
129
131
  batch.client = client;
130
132
  batch.operations = [];
133
+ // The document each queued op's strict-mode stamp is for (null for none),
134
+ // index for index with `operations`.
135
+ batch.stampedDocuments = [];
131
136
  batch.open = true;
132
137
  batch._request = (method, path, options = {}) =>
133
138
  queueRequest(batch, method, path, options);
@@ -138,6 +143,7 @@ function openBatch(client) {
138
143
  batch.submit = () => submitBatch(batch);
139
144
  batch.abort = () => {
140
145
  batch.operations = [];
146
+ batch.stampedDocuments = [];
141
147
  batch.open = false;
142
148
  };
143
149
  batch._installResources();
@@ -150,23 +156,66 @@ async function submitBatch(batch) {
150
156
  }
151
157
  batch.open = false;
152
158
  const ops = batch.operations;
159
+ const stamps = batch.stampedDocuments;
153
160
  batch.operations = [];
161
+ batch.stampedDocuments = [];
154
162
  if (ops.length === 0) return [];
155
- const url = `${batch.client.baseUrl}/api/v1/batch`;
163
+ const client = batch.client;
164
+ const url = `${client.baseUrl}/api/v1/batch`;
156
165
  // The server caps a batch at MAX_BATCH_OPS so one transaction cannot hold
157
166
  // the write lock without bound. A larger batch goes as consecutive
158
167
  // requests, results concatenated in queue order: it could not have been one
159
168
  // transaction anyway, and a repair or bulk edit over a big document must
160
- // not fail on its size alone.
169
+ // not fail on its size alone. Past the first request, a strict-mode stamp
170
+ // claims the version the requests before it left (learned from their
171
+ // results), not the one the document had when the op was queued, which the
172
+ // first request has already moved on.
173
+ //
174
+ // Each request is atomic, the whole is not: a failure leaves the requests
175
+ // before it saved. The error says so, as `committed` (how many operations
176
+ // were saved) and `committedResults` (their results, in queue order), so a
177
+ // caller can tell a partial write from a batch that saved nothing. The
178
+ // failed request itself counts as unsaved, even when its answer was lost.
161
179
  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
- );
180
+ try {
181
+ await sendChunks(client, url, ops, stamps, results);
182
+ } catch (error) {
183
+ if (error && typeof error === "object") {
184
+ error.committed = results.length;
185
+ error.committedResults = results;
186
+ }
187
+ throw error;
166
188
  }
167
189
  return results;
168
190
  }
169
191
 
192
+ async function sendChunks(client, url, ops, stamps, results) {
193
+ for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
194
+ let chunk = ops.slice(i, i + MAX_BATCH_OPS);
195
+ if (i > 0) {
196
+ chunk = chunk.map((op, j) => {
197
+ const docId = stamps[i + j];
198
+ if (!docId) return op;
199
+ const path = restampDocumentVersion(
200
+ op.path,
201
+ client.documentVersions[docId],
202
+ );
203
+ return { ...op, path };
204
+ });
205
+ }
206
+ results.push(...(await client._postBatch(url, chunk)));
207
+ }
208
+ }
209
+
210
+ /** The query of an events read: `types` as the wire's comma-separated list. */
211
+ function eventQuery(types, startTime, endTime) {
212
+ return {
213
+ types: Array.isArray(types) ? types.join(",") || undefined : types,
214
+ "start-time": startTime,
215
+ "end-time": endTime,
216
+ };
217
+ }
218
+
170
219
  class PlaidClient {
171
220
  /**
172
221
  * Create a new PlaidClient instance
@@ -193,6 +242,9 @@ class PlaidClient {
193
242
  ? options.timeout
194
243
  : DEFAULT_BATCH_TIMEOUT_MS;
195
244
  this.documentVersions = {};
245
+ // The server's clock minus this machine's, in ms, from the last response
246
+ // with a Date header (null before one). See serverNow().
247
+ this.serverClockOffsetMs = null;
196
248
  this.strictModeDocumentId = null;
197
249
  // Set to a DocumentLockLost while a `documents.locked()` block's keep-alive
198
250
  // has failed. Every write throws it until the block exits; see
@@ -212,6 +264,17 @@ class PlaidClient {
212
264
  this._installResources();
213
265
  }
214
266
 
267
+ /**
268
+ * The server's time now, as its last response's Date header put it (to the
269
+ * second), else this machine's. Judge a time the server stamped, such as an
270
+ * audit entry's `ts`, against this rather than `Date.now()`: a browser
271
+ * clock can be minutes off.
272
+ * @returns {Date}
273
+ */
274
+ serverNow() {
275
+ return new Date(Date.now() + (this.serverClockOffsetMs ?? 0));
276
+ }
277
+
215
278
  /**
216
279
  * The API bundles (`client.documents`, `client.tokens`, ...). Installed on
217
280
  * the client, and again on every batch it opens (see `batch()`): a batch is
@@ -261,7 +324,6 @@ class PlaidClient {
261
324
  this._request("PUT", `/api/v1/vocab-links/${id}/metadata`, {
262
325
  auditMessage,
263
326
  rawBody: body,
264
- skipResponseTransform: true,
265
327
  }),
266
328
  /**
267
329
  * Remove all metadata from a vocab link.
@@ -270,28 +332,23 @@ class PlaidClient {
270
332
  deleteMetadata: (id, auditMessage) =>
271
333
  this._request("DELETE", `/api/v1/vocab-links/${id}/metadata`, {
272
334
  auditMessage,
273
- skipResponseTransform: true,
274
335
  }),
275
336
  /**
276
- * 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.
337
+ * 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.
277
338
  * @param {string} id - The resource ID
278
- * @param {any} body - The metadata patch
339
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
279
340
  */
280
341
  patchMetadata: (id, body, auditMessage) =>
281
342
  this._request("PATCH", `/api/v1/vocab-links/${id}/metadata`, {
282
343
  auditMessage,
283
344
  rawBody: body,
284
- skipResponseTransform: true,
285
345
  }),
286
346
  /**
287
347
  * Get a vocab link by ID
288
348
  * @param {string} id - The resource ID
289
- * @param {string} [asOf] - Temporal query timestamp
290
349
  */
291
- get: (id, asOf) =>
292
- this._request("GET", `/api/v1/vocab-links/${id}`, {
293
- queryParams: { "as-of": asOf },
294
- }),
350
+ get: (id) =>
351
+ this._request("GET", `/api/v1/vocab-links/${id}`),
295
352
  /**
296
353
  * Delete a vocab link
297
354
  * @param {string} id - The resource ID
@@ -302,15 +359,113 @@ class PlaidClient {
302
359
 
303
360
  this.vocabLayers = {
304
361
  /**
305
- * Get a vocab layer by ID
362
+ * Get a vocab layer by ID. With `asOf`, the vocabulary as it was at that
363
+ * instant, read from its history: the same shape as the live read, its
364
+ * entries with `includeItems`. A vocabulary that did not exist then is a
365
+ * 404, a malformed or pruned time a 400.
306
366
  * @param {string} id - The resource ID
307
367
  * @param {boolean} [includeItems] - Include vocab items
308
- * @param {string} [asOf] - Temporal query timestamp
368
+ * @param {string} [asOf] - The moment to read at (ISO-8601 instant)
309
369
  */
310
370
  get: (id, includeItems, asOf) =>
311
371
  this._request("GET", `/api/v1/vocab-layers/${id}`, {
312
372
  queryParams: { "include-items": includeItems, "as-of": asOf },
313
373
  }),
374
+ /**
375
+ * One entry of the vocabulary as it was at `asOf`, also when it has
376
+ * been deleted since. The same shape as `vocabItems.get`. A 404 when
377
+ * the entry was not in this vocabulary at that time.
378
+ * @param {string} id - The vocabulary ID
379
+ * @param {string} itemId - The entry ID
380
+ * @param {string} asOf - The moment to read at (ISO-8601 instant)
381
+ */
382
+ getItemAt: (id, itemId, asOf) =>
383
+ this._request("GET", `/api/v1/vocab-layers/${id}/items/${itemId}`, {
384
+ queryParams: { "as-of": asOf },
385
+ }),
386
+ /**
387
+ * Get the audit log of a vocabulary: every change to it or to its
388
+ * entries, folded into entries as the document log is. Links are not
389
+ * listed, they belong to the document they annotate. Transparently
390
+ * follows pagination cursors and returns the full flat array.
391
+ * @param {string} id - The vocabulary ID
392
+ * @param {string} [startTime] - Start of time range
393
+ * @param {string} [endTime] - End of time range
394
+ * @param {string[]|string} [opTypes] - Only return operations of these
395
+ * types, spelled as in an entry's `op/type` (e.g.
396
+ * `['vocab-item/delete', 'vocab-item/restore']`)
397
+ * @param {string} [itemId] - Only the changes that wrote this one entry,
398
+ * each with only its operations that did
399
+ */
400
+ audit: (id, startTime, endTime, opTypes, itemId) =>
401
+ listAll(this, `/api/v1/vocab-layers/${id}/audit`, {
402
+ query: {
403
+ "start-time": startTime,
404
+ "end-time": endTime,
405
+ "op-types": opTypesParam(opTypes),
406
+ "item-id": itemId,
407
+ },
408
+ }),
409
+ /**
410
+ * One page of the same log, newest-first with `order: "desc"`. Use this
411
+ * rather than audit() wherever the caller wants the recent end of a log
412
+ * that may be long: audit() walks every page before it resolves.
413
+ * @param {string} id - The vocabulary ID
414
+ * @param {object} [opts]
415
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
416
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
417
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
418
+ * @param {string} [opts.itemId] - Only the changes that wrote this one
419
+ * entry, each with only its operations that did
420
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
421
+ */
422
+ auditPage: (
423
+ id,
424
+ { startTime, endTime, opTypes, order, limit, cursor, itemId } = {},
425
+ ) =>
426
+ listPage(this, `/api/v1/vocab-layers/${id}/audit`, {
427
+ limit,
428
+ cursor,
429
+ query: {
430
+ "start-time": startTime,
431
+ "end-time": endTime,
432
+ "op-types": opTypesParam(opTypes),
433
+ order,
434
+ "item-id": itemId,
435
+ },
436
+ }),
437
+ /**
438
+ * Put one entry of the vocabulary back as it was at `asOf`, as one
439
+ * operation. A deleted entry comes back under its original id with its
440
+ * form and fields, and a living one has its form and fields set back.
441
+ * Links are not part of an entry: a deleted entry's links come back
442
+ * through each document's own `documents.restore`. Resolves to
443
+ * `{inserted, form, metadata, total}`, `total` 0 when nothing changes.
444
+ * A form set back bumps every linking document, and the new versions
445
+ * come back in X-Document-Versions (or, past fifty documents,
446
+ * X-Document-Versions-Omitted), which the client takes up as on any
447
+ * write. A time when the entry did not exist is a 400. Maintainers of
448
+ * the vocabulary only.
449
+ * @param {string} id - The vocabulary ID
450
+ * @param {string} itemId - The entry ID
451
+ * @param {string} asOf - The moment to go back to (ISO-8601 instant),
452
+ * typically a history entry's `endTime`
453
+ * @param {object} [options] - `{ dryRun }`: with `dryRun` true nothing
454
+ * is written and the summary says what would change
455
+ * @param {string} [auditMessage] - Custom audit message for this operation
456
+ */
457
+ restoreItem: (id, itemId, asOf, { dryRun } = {}, auditMessage) =>
458
+ this._request(
459
+ "POST",
460
+ `/api/v1/vocab-layers/${id}/items/${itemId}/restore`,
461
+ {
462
+ queryParams: {
463
+ "as-of": asOf,
464
+ "dry-run": dryRun ? "true" : undefined,
465
+ },
466
+ auditMessage,
467
+ },
468
+ ),
314
469
  /**
315
470
  * Delete a vocab layer.
316
471
  * @param {string} id - The resource ID
@@ -355,35 +510,29 @@ class PlaidClient {
355
510
  /**
356
511
  * List all vocab layers accessible to user. Transparently follows
357
512
  * pagination cursors and returns the full flat array.
358
- * @param {string} [asOf] - Temporal query timestamp
359
513
  */
360
- list: (asOf) =>
361
- listAll(this, "/api/v1/vocab-layers", { query: { "as-of": asOf } }),
514
+ list: () => listAll(this, "/api/v1/vocab-layers"),
362
515
  /**
363
516
  * Fetch a single page of vocab layers.
364
517
  * @param {object} [opts]
365
518
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
366
519
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
367
- * @param {string} [opts.asOf] - Temporal query timestamp
368
520
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
369
521
  */
370
- listPage: ({ limit, cursor, asOf } = {}) =>
522
+ listPage: ({ limit, cursor } = {}) =>
371
523
  listPage(this, "/api/v1/vocab-layers", {
372
524
  limit,
373
525
  cursor,
374
- query: { "as-of": asOf },
375
526
  }),
376
527
  /**
377
528
  * Async-iterate vocab layers page by page; yields each page's entries array.
378
529
  * @param {object} [opts]
379
530
  * @param {number} [opts.pageSize] - Per-request page size
380
- * @param {string} [opts.asOf] - Temporal query timestamp
381
531
  * @returns {AsyncGenerator<Array>}
382
532
  */
383
- iterPages: ({ pageSize, asOf } = {}) =>
533
+ iterPages: ({ pageSize } = {}) =>
384
534
  iterPages(this, "/api/v1/vocab-layers", {
385
535
  pageSize,
386
- query: { "as-of": asOf },
387
536
  }),
388
537
  /**
389
538
  * Create a new vocab layer. Note: this also registers the user as a maintainer.
@@ -428,7 +577,6 @@ class PlaidClient {
428
577
  this._request("PUT", `/api/v1/relations/${relationId}/metadata`, {
429
578
  auditMessage,
430
579
  rawBody: body,
431
- skipResponseTransform: true,
432
580
  }),
433
581
  /**
434
582
  * Remove all metadata from a relation.
@@ -437,18 +585,16 @@ class PlaidClient {
437
585
  deleteMetadata: (relationId, auditMessage) =>
438
586
  this._request("DELETE", `/api/v1/relations/${relationId}/metadata`, {
439
587
  auditMessage,
440
- skipResponseTransform: true,
441
588
  }),
442
589
  /**
443
- * 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.
590
+ * 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.
444
591
  * @param {string} relationId - The relation ID
445
- * @param {any} body - The metadata patch
592
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
446
593
  */
447
594
  patchMetadata: (relationId, body, auditMessage) =>
448
595
  this._request("PATCH", `/api/v1/relations/${relationId}/metadata`, {
449
596
  auditMessage,
450
597
  rawBody: body,
451
- skipResponseTransform: true,
452
598
  }),
453
599
  /**
454
600
  * Update the target span of a relation.
@@ -463,12 +609,9 @@ class PlaidClient {
463
609
  /**
464
610
  * Get a relation by ID.
465
611
  * @param {string} relationId - The relation ID
466
- * @param {string} [asOf] - Temporal query timestamp
467
612
  */
468
- get: (relationId, asOf) =>
469
- this._request("GET", `/api/v1/relations/${relationId}`, {
470
- queryParams: { "as-of": asOf },
471
- }),
613
+ get: (relationId) =>
614
+ this._request("GET", `/api/v1/relations/${relationId}`),
472
615
  /**
473
616
  * Delete a relation.
474
617
  * @param {string} relationId - The relation ID
@@ -536,7 +679,7 @@ class PlaidClient {
536
679
  }),
537
680
  /**
538
681
  * Update many relations in a single operation: set values and/or patch metadata.
539
- * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a patch, a null value deleting that key. The relations may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions`. A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
682
+ * @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.
540
683
  * @returns {Promise<{count: number}>} How many relations were updated.
541
684
  */
542
685
  bulkUpdate: (body, auditMessage) =>
@@ -581,12 +724,9 @@ class PlaidClient {
581
724
  /**
582
725
  * Get a span layer by ID.
583
726
  * @param {string} spanLayerId - The span layer ID
584
- * @param {string} [asOf] - Temporal query timestamp
585
727
  */
586
- get: (spanLayerId, asOf) =>
587
- this._request("GET", `/api/v1/span-layers/${spanLayerId}`, {
588
- queryParams: { "as-of": asOf },
589
- }),
728
+ get: (spanLayerId) =>
729
+ this._request("GET", `/api/v1/span-layers/${spanLayerId}`),
590
730
  /**
591
731
  * Delete a span layer.
592
732
  * @param {string} spanLayerId - The span layer ID
@@ -659,12 +799,9 @@ class PlaidClient {
659
799
  /**
660
800
  * Get a span by ID.
661
801
  * @param {string} spanId - The span ID
662
- * @param {string} [asOf] - Temporal query timestamp
663
802
  */
664
- get: (spanId, asOf) =>
665
- this._request("GET", `/api/v1/spans/${spanId}`, {
666
- queryParams: { "as-of": asOf },
667
- }),
803
+ get: (spanId) =>
804
+ this._request("GET", `/api/v1/spans/${spanId}`),
668
805
  /**
669
806
  * Delete a span.
670
807
  * @param {string} spanId - The span ID
@@ -696,7 +833,7 @@ class PlaidClient {
696
833
  this._request("DELETE", "/api/v1/spans/bulk", { auditMessage, body }),
697
834
  /**
698
835
  * Update many spans in a single operation: set values and/or patch metadata.
699
- * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a patch, a null value deleting that key. The spans may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions`. A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
836
+ * @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.
700
837
  * @returns {Promise<{count: number}>} How many spans were updated.
701
838
  */
702
839
  bulkUpdate: (body, auditMessage) =>
@@ -713,7 +850,6 @@ class PlaidClient {
713
850
  this._request("PUT", `/api/v1/spans/${spanId}/metadata`, {
714
851
  auditMessage,
715
852
  rawBody: body,
716
- skipResponseTransform: true,
717
853
  }),
718
854
  /**
719
855
  * Remove all metadata from a span.
@@ -722,18 +858,16 @@ class PlaidClient {
722
858
  deleteMetadata: (spanId, auditMessage) =>
723
859
  this._request("DELETE", `/api/v1/spans/${spanId}/metadata`, {
724
860
  auditMessage,
725
- skipResponseTransform: true,
726
861
  }),
727
862
  /**
728
- * 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.
863
+ * 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.
729
864
  * @param {string} spanId - The span ID
730
- * @param {any} body - The metadata patch
865
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
731
866
  */
732
867
  patchMetadata: (spanId, body, auditMessage) =>
733
868
  this._request("PATCH", `/api/v1/spans/${spanId}/metadata`, {
734
869
  auditMessage,
735
870
  rawBody: body,
736
- skipResponseTransform: true,
737
871
  }),
738
872
  };
739
873
 
@@ -769,6 +903,24 @@ class PlaidClient {
769
903
  health: () => PlaidClient.health(this.baseUrl, { timeout: this.timeout }),
770
904
  };
771
905
 
906
+ // The signed-in user's sign-ins. Signing in is `PlaidClient.login`, which
907
+ // makes the client.
908
+ this.auth = {
909
+ /**
910
+ * End every sign-in of this user on every device: each browser tab,
911
+ * script and service holding one of the user's sign-in tokens is
912
+ * refused from now on, this client included. Named API tokens are not
913
+ * affected (revoke one with `apiTokens.revoke`). To sign out of one tab
914
+ * only, discard its token instead. A signal rather than project data:
915
+ * made on a batch it still goes out at once, and it never joins an
916
+ * operation.
917
+ * @returns {Promise<void>}
918
+ */
919
+ logoutEverywhere: async () => {
920
+ await this._request("POST", "/api/v1/logout", { outOfBand: true });
921
+ },
922
+ };
923
+
772
924
  this.texts = {
773
925
  /**
774
926
  * Replace all metadata for a text.
@@ -779,7 +931,6 @@ class PlaidClient {
779
931
  this._request("PUT", `/api/v1/texts/${textId}/metadata`, {
780
932
  auditMessage,
781
933
  rawBody: body,
782
- skipResponseTransform: true,
783
934
  }),
784
935
  /**
785
936
  * Remove all metadata from a text.
@@ -788,18 +939,16 @@ class PlaidClient {
788
939
  deleteMetadata: (textId, auditMessage) =>
789
940
  this._request("DELETE", `/api/v1/texts/${textId}/metadata`, {
790
941
  auditMessage,
791
- skipResponseTransform: true,
792
942
  }),
793
943
  /**
794
- * 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.
944
+ * 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.
795
945
  * @param {string} textId - The text ID
796
- * @param {any} body - The metadata patch
946
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
797
947
  */
798
948
  patchMetadata: (textId, body, auditMessage) =>
799
949
  this._request("PATCH", `/api/v1/texts/${textId}/metadata`, {
800
950
  auditMessage,
801
951
  rawBody: body,
802
- skipResponseTransform: true,
803
952
  }),
804
953
  /**
805
954
  * Create a new text in a document's text layer. A text is a container for
@@ -822,12 +971,9 @@ class PlaidClient {
822
971
  /**
823
972
  * Get a text.
824
973
  * @param {string} textId - The text ID
825
- * @param {string} [asOf] - Temporal query timestamp
826
974
  */
827
- get: (textId, asOf) =>
828
- this._request("GET", `/api/v1/texts/${textId}`, {
829
- queryParams: { "as-of": asOf },
830
- }),
975
+ get: (textId) =>
976
+ this._request("GET", `/api/v1/texts/${textId}`),
831
977
  /**
832
978
  * Delete a text and all dependent data.
833
979
  * @param {string} textId - The text ID
@@ -859,37 +1005,34 @@ class PlaidClient {
859
1005
  * returns the full flat array. Admin-or-maintainer only.
860
1006
  * @param {object} [opts]
861
1007
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
862
- * @param {string} [opts.asOf] - Temporal query timestamp
863
1008
  */
864
- list: ({ q, asOf } = {}) =>
865
- listAll(this, "/api/v1/users", { query: { q, "as-of": asOf } }),
1009
+ list: ({ q } = {}) =>
1010
+ listAll(this, "/api/v1/users", { query: { q } }),
866
1011
  /**
867
1012
  * Fetch a single page of users (optionally filtered by `q`).
868
1013
  * @param {object} [opts]
869
1014
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
870
1015
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
871
1016
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
872
- * @param {string} [opts.asOf] - Temporal query timestamp
873
1017
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
874
1018
  */
875
- listPage: ({ q, limit, cursor, asOf } = {}) =>
1019
+ listPage: ({ q, limit, cursor } = {}) =>
876
1020
  listPage(this, "/api/v1/users", {
877
1021
  limit,
878
1022
  cursor,
879
- query: { q, "as-of": asOf },
1023
+ query: { q },
880
1024
  }),
881
1025
  /**
882
1026
  * Async-iterate users page by page; yields each page's entries array.
883
1027
  * @param {object} [opts]
884
1028
  * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
885
1029
  * @param {number} [opts.pageSize] - Per-request page size
886
- * @param {string} [opts.asOf] - Temporal query timestamp
887
1030
  * @returns {AsyncGenerator<Array>}
888
1031
  */
889
- iterPages: ({ q, pageSize, asOf } = {}) =>
1032
+ iterPages: ({ q, pageSize } = {}) =>
890
1033
  iterPages(this, "/api/v1/users", {
891
1034
  pageSize,
892
- query: { q, "as-of": asOf },
1035
+ query: { q },
893
1036
  }),
894
1037
  /**
895
1038
  * Create a new user.
@@ -907,7 +1050,9 @@ class PlaidClient {
907
1050
  email,
908
1051
  password,
909
1052
  "is-admin": isAdmin,
910
- "display-name": displayName,
1053
+ // Left out when not given (null too), so core gives the user the
1054
+ // local part of the email. Core refuses a null display name.
1055
+ "display-name": displayName ?? undefined,
911
1056
  }),
912
1057
  }),
913
1058
  /**
@@ -916,18 +1061,16 @@ class PlaidClient {
916
1061
  * @param {string} userId - The user ID
917
1062
  * @param {string} [startTime] - Start of time range
918
1063
  * @param {string} [endTime] - End of time range
919
- * @param {string} [asOf] - Temporal query timestamp
920
1064
  * @param {string[]|string} [opTypes] - Only return operations of these
921
1065
  * types, spelled as in an entry's `op/type` (e.g.
922
1066
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
923
1067
  * one of its operations matches, carrying only the ones that did.
924
1068
  */
925
- audit: (userId, startTime, endTime, asOf, opTypes) =>
1069
+ audit: (userId, startTime, endTime, opTypes) =>
926
1070
  listAll(this, `/api/v1/users/${userId}/audit`, {
927
1071
  query: {
928
1072
  "start-time": startTime,
929
1073
  "end-time": endTime,
930
- "as-of": asOf,
931
1074
  "op-types": opTypesParam(opTypes),
932
1075
  },
933
1076
  }),
@@ -935,6 +1078,7 @@ class PlaidClient {
935
1078
  * One page of the same log, newest-first with `order: "desc"`. Use this
936
1079
  * rather than audit() wherever the caller wants the recent end of a log
937
1080
  * that may be long: audit() walks every page before it resolves.
1081
+ * @param {string} userId - The user ID
938
1082
  * @param {object} [opts]
939
1083
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
940
1084
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
@@ -943,7 +1087,7 @@ class PlaidClient {
943
1087
  */
944
1088
  auditPage: (
945
1089
  userId,
946
- { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
1090
+ { startTime, endTime, opTypes, order, limit, cursor } = {},
947
1091
  ) =>
948
1092
  listPage(this, `/api/v1/users/${userId}/audit`, {
949
1093
  limit,
@@ -951,7 +1095,6 @@ class PlaidClient {
951
1095
  query: {
952
1096
  "start-time": startTime,
953
1097
  "end-time": endTime,
954
- "as-of": asOf,
955
1098
  "op-types": opTypesParam(opTypes),
956
1099
  order,
957
1100
  },
@@ -959,12 +1102,9 @@ class PlaidClient {
959
1102
  /**
960
1103
  * Get a user by ID
961
1104
  * @param {string} id - The resource ID
962
- * @param {string} [asOf] - Temporal query timestamp
963
1105
  */
964
- get: (id, asOf) =>
965
- this._request("GET", `/api/v1/users/${id}`, {
966
- queryParams: { "as-of": asOf },
967
- }),
1106
+ get: (id) =>
1107
+ this._request("GET", `/api/v1/users/${id}`),
968
1108
  /**
969
1109
  * Deactivate a user. Users are never hard-deleted: deactivation
970
1110
  * rejects their logins and tokens, strips their project memberships
@@ -1142,7 +1282,7 @@ class PlaidClient {
1142
1282
  ),
1143
1283
  /**
1144
1284
  * Create or replace one private data entry. `value` is any JSON (up to
1145
- * 1 MB), stored as sent. Not audited, not batchable.
1285
+ * 1 MB). Not audited, not batchable.
1146
1286
  *
1147
1287
  * The server stores it verbatim, but this client recases object keys on
1148
1288
  * the way out and back like any other body (`myKey` <-> `my-key`), so a
@@ -1210,7 +1350,7 @@ class PlaidClient {
1210
1350
  * credential and is shown ONLY here — store it immediately. API tokens
1211
1351
  * do not expire and survive password changes / logout; revoke to kill.
1212
1352
  * @param {string} userId - The user ID who will own the token
1213
- * @param {string} name - A human label, e.g. "Stanza Parser"
1353
+ * @param {string} name - A human label, e.g. "Stanza parser"
1214
1354
  * @returns {Promise<{id: string, name: string, token: string}>}
1215
1355
  */
1216
1356
  create: (userId, name, auditMessage) =>
@@ -1626,12 +1766,9 @@ class PlaidClient {
1626
1766
  /**
1627
1767
  * Get a token layer by ID.
1628
1768
  * @param {string} tokenLayerId - The token layer ID
1629
- * @param {string} [asOf] - Temporal query timestamp
1630
1769
  */
1631
- get: (tokenLayerId, asOf) =>
1632
- this._request("GET", `/api/v1/token-layers/${tokenLayerId}`, {
1633
- queryParams: { "as-of": asOf },
1634
- }),
1770
+ get: (tokenLayerId) =>
1771
+ this._request("GET", `/api/v1/token-layers/${tokenLayerId}`),
1635
1772
  /**
1636
1773
  * Delete a token layer.
1637
1774
  * @param {string} tokenLayerId - The token layer ID
@@ -1656,14 +1793,15 @@ class PlaidClient {
1656
1793
  /**
1657
1794
  * Check the lock status of a document.
1658
1795
  * @param {string} documentId - The document ID
1659
- * @param {string} [asOf] - Temporal query timestamp
1660
1796
  */
1661
- checkLock: (documentId, asOf) =>
1662
- this._request("GET", `/api/v1/documents/${documentId}/lock`, {
1663
- queryParams: { "as-of": asOf },
1664
- }),
1797
+ checkLock: (documentId) =>
1798
+ this._request("GET", `/api/v1/documents/${documentId}/lock`),
1665
1799
  /**
1666
- * Acquire or refresh a document lock.
1800
+ * Acquire a document lock as a new holder.
1801
+ *
1802
+ * The answer's `lockId` names the holder: `renewLock` and `releaseLock`
1803
+ * take it. While the lock is held, a second acquire is refused with
1804
+ * HTTP 423, whoever makes it, this user included.
1667
1805
  *
1668
1806
  * outOfBand: the lock is a signal, not project data (see the note at
1669
1807
  * the top of http.js). Queued on a batch it would be taken only at
@@ -1678,13 +1816,30 @@ class PlaidClient {
1678
1816
  outOfBand: true,
1679
1817
  }),
1680
1818
  /**
1681
- * Release a document lock. outOfBand, for the same reason as
1819
+ * Renew the lock `lockId` holds while it is live. HTTP 423 if another
1820
+ * holder has it, and also once it has expired or been dropped, even when
1821
+ * nobody holds the document now: a renewal never takes a free document.
1822
+ * outOfBand, for the same reason as acquireLock.
1823
+ * @param {string} documentId - The document ID
1824
+ * @param {string} lockId - The `lockId` acquireLock answered with
1825
+ */
1826
+ renewLock: (documentId, lockId, auditMessage) =>
1827
+ this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1828
+ queryParams: { "lock-id": lockId },
1829
+ auditMessage,
1830
+ outOfBand: true,
1831
+ }),
1832
+ /**
1833
+ * Release the lock `lockId` holds. Idempotent: a lock that holder no
1834
+ * longer has is left alone. outOfBand, for the same reason as
1682
1835
  * acquireLock: queued, the lock would be held until the batch submits,
1683
1836
  * and not released at all if it aborts.
1684
1837
  * @param {string} documentId - The document ID
1838
+ * @param {string} lockId - The `lockId` acquireLock answered with
1685
1839
  */
1686
- releaseLock: (documentId, auditMessage) =>
1840
+ releaseLock: (documentId, lockId, auditMessage) =>
1687
1841
  this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
1842
+ queryParams: { "lock-id": lockId },
1688
1843
  auditMessage,
1689
1844
  outOfBand: true,
1690
1845
  }),
@@ -1702,9 +1857,9 @@ class PlaidClient {
1702
1857
  * tokenizer that deletes and recreates a document's tokens, spans and
1703
1858
  * relations. A single atomic call does not need it. While the lock is
1704
1859
  * held, writes to the document by ANOTHER user are refused with HTTP
1705
- * 423; the holder's own writes pass and renew it. If another user
1706
- * already holds it this rejects with a readable 423 and `fn` does not
1707
- * run.
1860
+ * 423; the holder's own writes pass and renew it. If anyone already
1861
+ * holds it, another block of this same user included, this rejects with
1862
+ * a readable 423 and `fn` does not run.
1708
1863
  *
1709
1864
  * The lock is renewed for as long as `fn` runs, so work that computes
1710
1865
  * for minutes before it writes holds the lock the whole time rather than
@@ -1714,11 +1869,13 @@ class PlaidClient {
1714
1869
  * success. `fn` receives a lock handle and may read `lock.lost` (or call
1715
1870
  * `lock.raiseIfLost()`) to give up sooner.
1716
1871
  *
1717
- * NOT re-entrant: nesting two `locked()` blocks on one document would
1718
- * release on the inner exit and leave the outer unprotected. Lock at
1719
- * exactly one level per call path. A lost lock is recorded on the
1720
- * CLIENT, like batch and strict mode, so it stops every write the client
1721
- * makes and not only the ones this block makes.
1872
+ * The lock is per HOLDER: each block is its own, named by
1873
+ * `lock.lockId`, and only that id renews or releases it. NOT
1874
+ * re-entrant: a nested `locked()` block on one document is a second
1875
+ * holder and gets the 423. Lock at exactly one level per call path. A
1876
+ * lost lock is recorded on the CLIENT, like strict mode, so it stops
1877
+ * every write the client makes (on any batch of it too) and not only the
1878
+ * ones this block makes.
1722
1879
  * @param {string} documentId - The document ID
1723
1880
  * @param {(lock: DocumentLock) => any} fn - The work to run while holding it
1724
1881
  * @param {object} [options] - `{ keepAlive }`: renew on a timer (default true)
@@ -1775,7 +1932,6 @@ class PlaidClient {
1775
1932
  this._request("PUT", `/api/v1/documents/${documentId}/metadata`, {
1776
1933
  auditMessage,
1777
1934
  rawBody: body,
1778
- skipResponseTransform: true,
1779
1935
  }),
1780
1936
  /**
1781
1937
  * Remove all metadata from a document.
@@ -1784,18 +1940,16 @@ class PlaidClient {
1784
1940
  deleteMetadata: (documentId, auditMessage) =>
1785
1941
  this._request("DELETE", `/api/v1/documents/${documentId}/metadata`, {
1786
1942
  auditMessage,
1787
- skipResponseTransform: true,
1788
1943
  }),
1789
1944
  /**
1790
- * 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.
1945
+ * 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.
1791
1946
  * @param {string} documentId - The document ID
1792
- * @param {any} body - The metadata patch
1947
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
1793
1948
  */
1794
1949
  patchMetadata: (documentId, body, auditMessage) =>
1795
1950
  this._request("PATCH", `/api/v1/documents/${documentId}/metadata`, {
1796
1951
  auditMessage,
1797
1952
  rawBody: body,
1798
- skipResponseTransform: true,
1799
1953
  }),
1800
1954
  /**
1801
1955
  * Get audit log for a document. Transparently follows pagination cursors
@@ -1803,18 +1957,16 @@ class PlaidClient {
1803
1957
  * @param {string} documentId - The document ID
1804
1958
  * @param {string} [startTime] - Start of time range
1805
1959
  * @param {string} [endTime] - End of time range
1806
- * @param {string} [asOf] - Temporal query timestamp
1807
1960
  * @param {string[]|string} [opTypes] - Only return operations of these
1808
1961
  * types, spelled as in an entry's `op/type` (e.g.
1809
1962
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1810
1963
  * one of its operations matches, carrying only the ones that did.
1811
1964
  */
1812
- audit: (documentId, startTime, endTime, asOf, opTypes) =>
1965
+ audit: (documentId, startTime, endTime, opTypes) =>
1813
1966
  listAll(this, `/api/v1/documents/${documentId}/audit`, {
1814
1967
  query: {
1815
1968
  "start-time": startTime,
1816
1969
  "end-time": endTime,
1817
- "as-of": asOf,
1818
1970
  "op-types": opTypesParam(opTypes),
1819
1971
  },
1820
1972
  }),
@@ -1822,6 +1974,7 @@ class PlaidClient {
1822
1974
  * One page of the same log, newest-first with `order: "desc"`. Use this
1823
1975
  * rather than audit() wherever the caller wants the recent end of a log
1824
1976
  * that may be long: audit() walks every page before it resolves.
1977
+ * @param {string} documentId - The document ID
1825
1978
  * @param {object} [opts]
1826
1979
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
1827
1980
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
@@ -1830,7 +1983,7 @@ class PlaidClient {
1830
1983
  */
1831
1984
  auditPage: (
1832
1985
  documentId,
1833
- { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
1986
+ { startTime, endTime, opTypes, order, limit, cursor } = {},
1834
1987
  ) =>
1835
1988
  listPage(this, `/api/v1/documents/${documentId}/audit`, {
1836
1989
  limit,
@@ -1838,7 +1991,6 @@ class PlaidClient {
1838
1991
  query: {
1839
1992
  "start-time": startTime,
1840
1993
  "end-time": endTime,
1841
- "as-of": asOf,
1842
1994
  "op-types": opTypesParam(opTypes),
1843
1995
  order,
1844
1996
  },
@@ -2026,18 +2178,16 @@ class PlaidClient {
2026
2178
  * @param {string} projectId - The project ID
2027
2179
  * @param {string} [startTime] - Start of time range
2028
2180
  * @param {string} [endTime] - End of time range
2029
- * @param {string} [asOf] - Temporal query timestamp
2030
2181
  * @param {string[]|string} [opTypes] - Only return operations of these
2031
2182
  * types, spelled as in an entry's `op/type` (e.g.
2032
2183
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
2033
2184
  * one of its operations matches, carrying only the ones that did.
2034
2185
  */
2035
- audit: (projectId, startTime, endTime, asOf, opTypes) =>
2186
+ audit: (projectId, startTime, endTime, opTypes) =>
2036
2187
  listAll(this, `/api/v1/projects/${projectId}/audit`, {
2037
2188
  query: {
2038
2189
  "start-time": startTime,
2039
2190
  "end-time": endTime,
2040
- "as-of": asOf,
2041
2191
  "op-types": opTypesParam(opTypes),
2042
2192
  },
2043
2193
  }),
@@ -2045,6 +2195,7 @@ class PlaidClient {
2045
2195
  * One page of the same log, newest-first with `order: "desc"`. Use this
2046
2196
  * rather than audit() wherever the caller wants the recent end of a log
2047
2197
  * that may be long: audit() walks every page before it resolves.
2198
+ * @param {string} projectId - The project ID
2048
2199
  * @param {object} [opts]
2049
2200
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2050
2201
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
@@ -2053,7 +2204,7 @@ class PlaidClient {
2053
2204
  */
2054
2205
  auditPage: (
2055
2206
  projectId,
2056
- { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
2207
+ { startTime, endTime, opTypes, order, limit, cursor } = {},
2057
2208
  ) =>
2058
2209
  listPage(this, `/api/v1/projects/${projectId}/audit`, {
2059
2210
  limit,
@@ -2061,7 +2212,6 @@ class PlaidClient {
2061
2212
  query: {
2062
2213
  "start-time": startTime,
2063
2214
  "end-time": endTime,
2064
- "as-of": asOf,
2065
2215
  "op-types": opTypesParam(opTypes),
2066
2216
  order,
2067
2217
  },
@@ -2102,12 +2252,9 @@ class PlaidClient {
2102
2252
  * Get a project by ID. To fetch the project's documents, use
2103
2253
  * listDocuments(id) — the include-documents flag has been removed.
2104
2254
  * @param {string} id - The resource ID
2105
- * @param {string} [asOf] - Temporal query timestamp
2106
2255
  */
2107
- get: (id, asOf) =>
2108
- this._request("GET", `/api/v1/projects/${id}`, {
2109
- queryParams: { "as-of": asOf },
2110
- }),
2256
+ get: (id) =>
2257
+ this._request("GET", `/api/v1/projects/${id}`),
2111
2258
  /**
2112
2259
  * List all documents in a project. Transparently follows pagination
2113
2260
  * cursors and returns the full flat array.
@@ -2144,17 +2291,15 @@ class PlaidClient {
2144
2291
  iterDocuments: (id, { pageSize } = {}) =>
2145
2292
  iterPages(this, `/api/v1/projects/${id}/documents`, { pageSize }),
2146
2293
  /**
2147
- * Delete a project and everything in it. This is irrecoverable.
2148
- *
2149
- * For a large project this can take a long time, so the client's
2150
- * per-request timeout is DISABLED by default. Pass `{ timeout }` to
2151
- * impose a finite bound.
2294
+ * Delete a project and everything in it. This is irrecoverable. The
2295
+ * project is gone when this returns, and what it holds is removed on
2296
+ * the server afterwards.
2152
2297
  * @param {string} id - The resource ID
2153
2298
  * @param {string} [auditMessage] - Custom audit-log message
2154
2299
  * @param {object} [options]
2155
- * @param {number} [options.timeout=0] - Per-request timeout in ms (0/null disables)
2300
+ * @param {number} [options.timeout] - Per-request timeout in ms, the client's own by default (0/null disables)
2156
2301
  */
2157
- delete: (id, auditMessage, { timeout = 0 } = {}) =>
2302
+ delete: (id, auditMessage, { timeout } = {}) =>
2158
2303
  this._request("DELETE", `/api/v1/projects/${id}`, {
2159
2304
  auditMessage,
2160
2305
  timeout,
@@ -2172,35 +2317,29 @@ class PlaidClient {
2172
2317
  /**
2173
2318
  * List all projects accessible to user. Transparently follows pagination
2174
2319
  * cursors and returns the full flat array.
2175
- * @param {string} [asOf] - Temporal query timestamp
2176
2320
  */
2177
- list: (asOf) =>
2178
- listAll(this, "/api/v1/projects", { query: { "as-of": asOf } }),
2321
+ list: () => listAll(this, "/api/v1/projects"),
2179
2322
  /**
2180
2323
  * Fetch a single page of projects.
2181
2324
  * @param {object} [opts]
2182
2325
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2183
2326
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2184
- * @param {string} [opts.asOf] - Temporal query timestamp
2185
2327
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2186
2328
  */
2187
- listPage: ({ limit, cursor, asOf } = {}) =>
2329
+ listPage: ({ limit, cursor } = {}) =>
2188
2330
  listPage(this, "/api/v1/projects", {
2189
2331
  limit,
2190
2332
  cursor,
2191
- query: { "as-of": asOf },
2192
2333
  }),
2193
2334
  /**
2194
2335
  * Async-iterate projects page by page; yields each page's entries array.
2195
2336
  * @param {object} [opts]
2196
2337
  * @param {number} [opts.pageSize] - Per-request page size
2197
- * @param {string} [opts.asOf] - Temporal query timestamp
2198
2338
  * @returns {AsyncGenerator<Array>}
2199
2339
  */
2200
- iterPages: ({ pageSize, asOf } = {}) =>
2340
+ iterPages: ({ pageSize } = {}) =>
2201
2341
  iterPages(this, "/api/v1/projects", {
2202
2342
  pageSize,
2203
- query: { "as-of": asOf },
2204
2343
  }),
2205
2344
  /**
2206
2345
  * Create a new project. Note: this also registers the user as a maintainer.
@@ -2248,12 +2387,9 @@ class PlaidClient {
2248
2387
  /**
2249
2388
  * Get a text layer by ID.
2250
2389
  * @param {string} textLayerId - The text layer ID
2251
- * @param {string} [asOf] - Temporal query timestamp
2252
2390
  */
2253
- get: (textLayerId, asOf) =>
2254
- this._request("GET", `/api/v1/text-layers/${textLayerId}`, {
2255
- queryParams: { "as-of": asOf },
2256
- }),
2391
+ get: (textLayerId) =>
2392
+ this._request("GET", `/api/v1/text-layers/${textLayerId}`),
2257
2393
  /**
2258
2394
  * Delete a text layer.
2259
2395
  * @param {string} textLayerId - The text layer ID
@@ -2304,7 +2440,6 @@ class PlaidClient {
2304
2440
  this._request("PUT", `/api/v1/vocab-items/${id}/metadata`, {
2305
2441
  auditMessage,
2306
2442
  rawBody: body,
2307
- skipResponseTransform: true,
2308
2443
  }),
2309
2444
  /**
2310
2445
  * Remove all metadata from a vocab item.
@@ -2313,18 +2448,16 @@ class PlaidClient {
2313
2448
  deleteMetadata: (id, auditMessage) =>
2314
2449
  this._request("DELETE", `/api/v1/vocab-items/${id}/metadata`, {
2315
2450
  auditMessage,
2316
- skipResponseTransform: true,
2317
2451
  }),
2318
2452
  /**
2319
- * 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.
2453
+ * 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.
2320
2454
  * @param {string} id - The resource ID
2321
- * @param {any} body - The metadata patch
2455
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
2322
2456
  */
2323
2457
  patchMetadata: (id, body, auditMessage) =>
2324
2458
  this._request("PATCH", `/api/v1/vocab-items/${id}/metadata`, {
2325
2459
  auditMessage,
2326
2460
  rawBody: body,
2327
- skipResponseTransform: true,
2328
2461
  }),
2329
2462
  /**
2330
2463
  * Create a new vocab item
@@ -2350,7 +2483,7 @@ class PlaidClient {
2350
2483
  /**
2351
2484
  * Update many vocab items in a single operation: set forms and/or patch
2352
2485
  * 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.
2486
+ * @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.
2354
2487
  * @returns {Promise<{count: number}>} How many vocab items were updated.
2355
2488
  */
2356
2489
  bulkUpdate: (body, auditMessage) =>
@@ -2374,12 +2507,9 @@ class PlaidClient {
2374
2507
  /**
2375
2508
  * Get a vocab item by ID
2376
2509
  * @param {string} id - The resource ID
2377
- * @param {string} [asOf] - Temporal query timestamp
2378
2510
  */
2379
- get: (id, asOf) =>
2380
- this._request("GET", `/api/v1/vocab-items/${id}`, {
2381
- queryParams: { "as-of": asOf },
2382
- }),
2511
+ get: (id) =>
2512
+ this._request("GET", `/api/v1/vocab-items/${id}`),
2383
2513
  /**
2384
2514
  * Delete a vocab item, and every link to it. Every document holding one
2385
2515
  * of those links has its version bumped, and a strict-mode client picks
@@ -2459,12 +2589,9 @@ class PlaidClient {
2459
2589
  /**
2460
2590
  * Get a relation layer by ID.
2461
2591
  * @param {string} relationLayerId - The relation layer ID
2462
- * @param {string} [asOf] - Temporal query timestamp
2463
2592
  */
2464
- get: (relationLayerId, asOf) =>
2465
- this._request("GET", `/api/v1/relation-layers/${relationLayerId}`, {
2466
- queryParams: { "as-of": asOf },
2467
- }),
2593
+ get: (relationLayerId) =>
2594
+ this._request("GET", `/api/v1/relation-layers/${relationLayerId}`),
2468
2595
  /**
2469
2596
  * Delete a relation layer.
2470
2597
  * @param {string} relationLayerId - The relation layer ID
@@ -2527,12 +2654,9 @@ class PlaidClient {
2527
2654
  /**
2528
2655
  * Get a token.
2529
2656
  * @param {string} tokenId - The token ID
2530
- * @param {string} [asOf] - Temporal query timestamp
2531
2657
  */
2532
- get: (tokenId, asOf) =>
2533
- this._request("GET", `/api/v1/tokens/${tokenId}`, {
2534
- queryParams: { "as-of": asOf },
2535
- }),
2658
+ get: (tokenId) =>
2659
+ this._request("GET", `/api/v1/tokens/${tokenId}`),
2536
2660
  /**
2537
2661
  * Delete a token and remove it from any spans. If this causes a span to
2538
2662
  * have no remaining tokens, the span will also be deleted.
@@ -2570,7 +2694,7 @@ class PlaidClient {
2570
2694
  this._request("DELETE", "/api/v1/tokens/bulk", { auditMessage, body }),
2571
2695
  /**
2572
2696
  * Update many tokens in a single operation: patch metadata.
2573
- * @param {Array} body - Objects of the shape `{id, metadata}`. `metadata` is a patch, a null value deleting that key. The tokens may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions`. A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
2697
+ * @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.
2574
2698
  * @returns {Promise<{count: number}>} How many tokens were updated.
2575
2699
  */
2576
2700
  bulkUpdate: (body, auditMessage) =>
@@ -2624,7 +2748,6 @@ class PlaidClient {
2624
2748
  this._request("PUT", `/api/v1/tokens/${tokenId}/metadata`, {
2625
2749
  auditMessage,
2626
2750
  rawBody: body,
2627
- skipResponseTransform: true,
2628
2751
  }),
2629
2752
  /**
2630
2753
  * Remove all metadata from a token.
@@ -2633,18 +2756,16 @@ class PlaidClient {
2633
2756
  deleteMetadata: (tokenId, auditMessage) =>
2634
2757
  this._request("DELETE", `/api/v1/tokens/${tokenId}/metadata`, {
2635
2758
  auditMessage,
2636
- skipResponseTransform: true,
2637
2759
  }),
2638
2760
  /**
2639
- * 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.
2761
+ * 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.
2640
2762
  * @param {string} tokenId - The token ID
2641
- * @param {any} body - The metadata patch
2763
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
2642
2764
  */
2643
2765
  patchMetadata: (tokenId, body, auditMessage) =>
2644
2766
  this._request("PATCH", `/api/v1/tokens/${tokenId}/metadata`, {
2645
2767
  auditMessage,
2646
2768
  rawBody: body,
2647
- skipResponseTransform: true,
2648
2769
  }),
2649
2770
  };
2650
2771
 
@@ -2668,13 +2789,17 @@ class PlaidClient {
2668
2789
  * this a key such as `case-marker` would reach listeners as
2669
2790
  * `caseMarker` in JS and `case_marker` in Python. `listen` restores it
2670
2791
  * verbatim on the way in.
2792
+ *
2793
+ * A message is not saved and writes nothing to History, so it never
2794
+ * joins an open operation (`withOperation`). Made on a batch it still
2795
+ * queues, so a message queued after the writes goes out after them.
2671
2796
  * @param {string} projectId - The UUID of the project to send to
2672
2797
  * @param {any} data - The message data to send
2673
2798
  * @returns {Promise<any>} Response from the send operation
2674
2799
  */
2675
- sendMessage: (projectId, data, auditMessage) =>
2800
+ sendMessage: (projectId, data) =>
2676
2801
  this._request("POST", `/api/v1/projects/${projectId}/message`, {
2677
- auditMessage,
2802
+ noOperation: true,
2678
2803
  rawBody: { body: data },
2679
2804
  }),
2680
2805
 
@@ -2707,7 +2832,9 @@ class PlaidClient {
2707
2832
  * @param {function} onServiceRequest - Callback (data, responseHelper)
2708
2833
  * @param {Object} [extras] - Optional additional service metadata
2709
2834
  * @param {function} [onStatus] - Optional callback (event, projectId, detail) for
2710
- * connection transitions: 'registered', 'reconnected', 'disconnected'
2835
+ * connection transitions: 'registered', 'reconnected', 'disconnected',
2836
+ * and 'stopped' when the server refuses the channel for good, which
2837
+ * ends the registration
2711
2838
  * @returns {Object} Service registration with .stop(), .isRunning(), .isConnected()
2712
2839
  */
2713
2840
  serve: (projectId, serviceInfo, onServiceRequest, extras, onStatus) =>
@@ -2729,7 +2856,7 @@ class PlaidClient {
2729
2856
  * @param {number} [timeout=10000] - Timeout in ms
2730
2857
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
2731
2858
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
2732
- * @param {Object} [opts] - {requestId, onAccepted}
2859
+ * @param {Object} [opts] - {requestId, onAccepted, projectIds, noOperation}
2733
2860
  * @returns {Promise<any>} The service's result
2734
2861
  */
2735
2862
  requestService: (
@@ -2827,18 +2954,15 @@ class PlaidClient {
2827
2954
  * @param {Object} body - The query AST ({find, where, scope?, limit?, orderBy?,
2828
2955
  * return?, bindings?}).
2829
2956
  * @returns {Promise<Object>} For 'ids'/'entities': {columns, results, count, truncated}.
2830
- * For 'count': {return: 'count', count}. Entity cells are full entity objects
2831
- * (same shape as the GET endpoints).
2957
+ * For 'count': {return: 'count', count, truncated}, where `truncated` says
2958
+ * the count stopped at the server's cap. Entity cells are full entity
2959
+ * objects (same shape as the GET endpoints).
2832
2960
  */
2833
2961
  // outOfBand: a query is a read that travels as a POST (see the note at
2834
2962
  // the top of http.js). Made on a batch it goes over the wire like any
2835
2963
  // read, and it never joins a logical operation.
2836
- this.query = (body, auditMessage) =>
2837
- this._request("POST", "/api/v1/query", {
2838
- auditMessage,
2839
- body,
2840
- outOfBand: true,
2841
- });
2964
+ this.query = (body) =>
2965
+ this._request("POST", "/api/v1/query", { body, outOfBand: true });
2842
2966
 
2843
2967
  // Logical-operation groups (audit-log grouping). There is no create: a
2844
2968
  // group row is made lazily by the first write carrying `?group-id=`
@@ -3015,6 +3139,20 @@ class PlaidClient {
3015
3139
  cursor,
3016
3140
  query: { "entity-id": entityId },
3017
3141
  }),
3142
+ /**
3143
+ * Async-iterate a vocabulary's comments page by page, oldest first;
3144
+ * yields each page's entries array.
3145
+ * @param {string} vocabId - The vocab layer to read
3146
+ * @param {object} [opts]
3147
+ * @param {string} [opts.entityId] - One entry's thread
3148
+ * @param {number} [opts.pageSize] - Per-request page size
3149
+ * @returns {AsyncGenerator<Array>}
3150
+ */
3151
+ iterInVocabPages: (vocabId, { entityId, pageSize } = {}) =>
3152
+ iterPages(this, `/api/v1/vocab-layers/${vocabId}/comments`, {
3153
+ pageSize,
3154
+ query: { "entity-id": entityId },
3155
+ }),
3018
3156
  /**
3019
3157
  * Comment counts per entry of a vocabulary, as an `{entityId: n}` map.
3020
3158
  * The response is NOT key-transformed: its keys are entry ids.
@@ -3034,6 +3172,100 @@ class PlaidClient {
3034
3172
  ),
3035
3173
  };
3036
3174
 
3175
+ this.events = {
3176
+ /**
3177
+ * Record one research-telemetry event (core manual, "Research
3178
+ * telemetry"). Fire and forget: the event is buffered and sent with
3179
+ * others every ten seconds, at fifty events, and when the page is
3180
+ * hidden. A batch that fails is dropped, nothing is retried, and
3181
+ * nothing is ever thrown. Sends nothing for a project whose switch
3182
+ * (`config.plaid.research.telemetry`) is off, which it reads from the
3183
+ * project itself. `suggestion.shown` is recorded once per target,
3184
+ * field and value in a page session.
3185
+ * @param {'suggestion.shown'|'suggestion.adopted'|'suggestion.dismissed'|'plan.opened'} type - The event type
3186
+ * @param {object} fields
3187
+ * @param {string} fields.projectId - The project it happened in
3188
+ * @param {string} [fields.documentId] - The document it happened in
3189
+ * @param {string} [fields.targetId] - What it is about: a token, a plan
3190
+ * @param {object} [fields.data] - Its details, keyed by single lowercase words (`value`, `source`, `field`, `written`, `conversation`)
3191
+ * @returns {boolean} Whether the event was buffered
3192
+ */
3193
+ record: (type, fields) => recorderFor(this).record(this, type, fields),
3194
+ /**
3195
+ * Send what the recorder holds now, rather than at its next interval.
3196
+ */
3197
+ flush: () => recorderFor(this).flush(),
3198
+ /**
3199
+ * Tell the recorder a project's switch at once, e.g. right after a
3200
+ * maintainer changed it. Turning it off drops what is buffered.
3201
+ * @param {string} projectId - The project
3202
+ * @param {boolean} on - Whether telemetry is on
3203
+ */
3204
+ setEnabled: (projectId, on) => recorderFor(this).setEnabled(projectId, on),
3205
+ /**
3206
+ * Record events directly, with no buffering. Requires write access, and
3207
+ * the server refuses with a 403 while the project's switch is off. All
3208
+ * or nothing: one bad event refuses the request with a 400 naming it.
3209
+ * @param {string} projectId - The project the events happened in
3210
+ * @param {Array<{type: string, documentId?: string, targetId?: string, data?: object, clientTs?: string}>} events - At most 500 events
3211
+ * @returns {Promise<{count: number}>}
3212
+ */
3213
+ create: (projectId, events) =>
3214
+ this._request("POST", `/api/v1/projects/${projectId}/events`, {
3215
+ body: events,
3216
+ outOfBand: true,
3217
+ }),
3218
+ /**
3219
+ * A project's events in arrival order. Maintainer or admin only.
3220
+ * Transparently follows pagination cursors and returns the full flat
3221
+ * array.
3222
+ * @param {string} projectId - The project to read
3223
+ * @param {object} [filters]
3224
+ * @param {string|string[]} [filters.types] - Only these types
3225
+ * @param {string} [filters.startTime] - Only events the server stamped at or after this instant
3226
+ * @param {string} [filters.endTime] - Only events the server stamped at or before this instant
3227
+ */
3228
+ list: (projectId, { types, startTime, endTime } = {}) =>
3229
+ listAll(this, `/api/v1/projects/${projectId}/events`, {
3230
+ query: eventQuery(types, startTime, endTime),
3231
+ }),
3232
+ /**
3233
+ * One page of a project's events. Maintainer or admin only.
3234
+ * @param {string} projectId - The project to read
3235
+ * @param {object} [opts]
3236
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
3237
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
3238
+ * @param {string|string[]} [opts.types] - Only these types
3239
+ * @param {string} [opts.startTime] - Only events at or after this instant
3240
+ * @param {string} [opts.endTime] - Only events at or before this instant
3241
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
3242
+ */
3243
+ listPage: (
3244
+ projectId,
3245
+ { limit, cursor, types, startTime, endTime } = {},
3246
+ ) =>
3247
+ listPage(this, `/api/v1/projects/${projectId}/events`, {
3248
+ limit,
3249
+ cursor,
3250
+ query: eventQuery(types, startTime, endTime),
3251
+ }),
3252
+ /**
3253
+ * Async-iterate a project's events page by page; yields each page's entries array.
3254
+ * @param {string} projectId - The project to read
3255
+ * @param {object} [opts]
3256
+ * @param {number} [opts.pageSize] - Per-request page size
3257
+ * @param {string|string[]} [opts.types] - Only these types
3258
+ * @param {string} [opts.startTime] - Only events at or after this instant
3259
+ * @param {string} [opts.endTime] - Only events at or before this instant
3260
+ * @returns {AsyncGenerator<Array>}
3261
+ */
3262
+ iterPages: (projectId, { pageSize, types, startTime, endTime } = {}) =>
3263
+ iterPages(this, `/api/v1/projects/${projectId}/events`, {
3264
+ pageSize,
3265
+ query: eventQuery(types, startTime, endTime),
3266
+ }),
3267
+ };
3268
+
3037
3269
  this.guidelines = {
3038
3270
  /**
3039
3271
  * Create a guideline in a project. `title` is the handle an assistant
@@ -3177,14 +3409,22 @@ class PlaidClient {
3177
3409
  * "Every write" is the writes of project data. Reads never join, and neither
3178
3410
  * do the out-of-band signals shaped like a write (a document lock taken or
3179
3411
  * renewed, a stopped service request, a service reporting itself, an admin
3180
- * control): none of them is audited, so there would be nothing under the
3181
- * label.
3412
+ * control), and neither does a broadcast message (`messages.sendMessage`):
3413
+ * none of them is audited, so there would be nothing under the label.
3414
+ *
3415
+ * `kind` says what kind of operation this is, for a program reading the
3416
+ * log: one of `assistant-plan`, `service-run`, `import`, `bulk-edit`,
3417
+ * `guess-adoption` or `repair` (the server refuses any other). `ref` is a
3418
+ * short string naming what the operation came from, in the shape its kind
3419
+ * documents (the core manual, "Kinds of operation"). Both are recorded from
3420
+ * the first write like the label, and a nested operation keeps the outer
3421
+ * one's.
3182
3422
  *
3183
3423
  * @param {string} message - Human label for the operation.
3184
- * @param {object} [opts] - Optional `{ id }`: adopt an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically).
3424
+ * @param {object} [opts] - Optional `{ id, kind, ref }`. `id` adopts an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically). `kind` and `ref` are described above.
3185
3425
  * @returns {string} The operation's group id.
3186
3426
  */
3187
- beginOperation(message, { id } = {}) {
3427
+ beginOperation(message, { id, kind, ref } = {}) {
3188
3428
  if (this.operationGroup) {
3189
3429
  this.operationGroup.depth += 1;
3190
3430
  return this.operationGroup.id;
@@ -3192,6 +3432,8 @@ class PlaidClient {
3192
3432
  this.operationGroup = {
3193
3433
  id: id || crypto.randomUUID(),
3194
3434
  message: message == null ? null : String(message),
3435
+ kind: kind == null ? null : String(kind),
3436
+ ref: ref == null ? null : String(ref),
3195
3437
  depth: 1,
3196
3438
  written: false,
3197
3439
  refined: undefined,
@@ -3238,12 +3480,17 @@ class PlaidClient {
3238
3480
  * setMessage(`Merged ${n} morphemes`);
3239
3481
  * });
3240
3482
  *
3483
+ * The kind and reference (see beginOperation) come after `fn`:
3484
+ *
3485
+ * await client.withOperation('Import ELAN corpus', run, { kind: 'import', ref: 'format:elan' });
3486
+ *
3241
3487
  * @param {string} message - Human label for the operation.
3242
3488
  * @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
3489
+ * @param {object} [opts] - Optional `{ kind, ref }`, as for beginOperation.
3243
3490
  * @returns {Promise<any>} Whatever `fn` resolves to.
3244
3491
  */
3245
- async withOperation(message, fn) {
3246
- this.beginOperation(message);
3492
+ async withOperation(message, fn, { kind, ref } = {}) {
3493
+ this.beginOperation(message, { kind, ref });
3247
3494
  const group = this.operationGroup;
3248
3495
  const setMessage = (msg) => {
3249
3496
  if (group.depth === 1) group.refined = msg;
@@ -3259,7 +3506,10 @@ class PlaidClient {
3259
3506
  * Open a batch: a view of this client with the same bundles, on which every
3260
3507
  * write of project data queues instead of going out. `submit()` sends the
3261
3508
  * queued operations as ONE atomic request (larger than the server's cap,
3262
- * as consecutive requests with the results concatenated in queue order) and
3509
+ * as consecutive requests with the results concatenated in queue order,
3510
+ * each atomic on its own, so a failure in a later one leaves the earlier
3511
+ * ones committed, and the error it throws carries `committed`, the count
3512
+ * saved, and `committedResults`, their results) and
3263
3513
  * resolves to one result per operation; `abort()` drops them. A call made
3264
3514
  * on the client itself is never touched by an open batch, and a read or an
3265
3515
  * out-of-band signal made on the batch goes over the wire now (see the note
@@ -3280,7 +3530,11 @@ class PlaidClient {
3280
3530
  return openBatch(this);
3281
3531
  }
3282
3532
 
3283
- /** POST one batch request (at most MAX_BATCH_OPS operations); resolves to its transformed results. */
3533
+ /**
3534
+ * POST one batch request (at most MAX_BATCH_OPS operations). Resolves to
3535
+ * one `{ status, headers, body }` per operation, with only the body
3536
+ * recased: the headers keep the server's spelling.
3537
+ */
3284
3538
  async _postBatch(url, body) {
3285
3539
  const fetchOptions = {
3286
3540
  method: "POST",
@@ -3305,6 +3559,22 @@ class PlaidClient {
3305
3559
  throw makeHttpError(res, await parseErrorBody(res), url, "POST");
3306
3560
  }
3307
3561
  return res;
3562
+ }).catch((error) => {
3563
+ // 401 means the token is missing, expired or invalid: fire the app's
3564
+ // auth-error handler once, exactly as makeRequest does.
3565
+ if (
3566
+ error?.status === 401 &&
3567
+ typeof this.onAuthError === "function" &&
3568
+ !this._authErrorFired
3569
+ ) {
3570
+ this._authErrorFired = true;
3571
+ try {
3572
+ this.onAuthError(error);
3573
+ } catch (_) {
3574
+ /* handler must not mask the original error */
3575
+ }
3576
+ }
3577
+ throw error;
3308
3578
  });
3309
3579
 
3310
3580
  const results = await response.json();
@@ -3332,7 +3602,10 @@ class PlaidClient {
3332
3602
  }
3333
3603
  }
3334
3604
 
3335
- return results.map((result) => transformResponse(result));
3605
+ return results.map((result) => ({
3606
+ ...result,
3607
+ body: transformResponse(result.body),
3608
+ }));
3336
3609
  } catch (error) {
3337
3610
  if (error.status) throw error;
3338
3611
  throw makeNetworkError(error, url, "POST");
@@ -3340,8 +3613,9 @@ class PlaidClient {
3340
3613
  }
3341
3614
 
3342
3615
  /**
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
3616
+ * Run `fn` with a batch, then submit all queued ops as `submit()` does (ONE
3617
+ * atomic request up to MAX_BATCH_OPS operations, consecutive requests past
3618
+ * it), or abort the batch if `fn` throws. `fn` receives the batch and makes its
3345
3619
  * writes on it; it must NOT call `submit()` itself. Resolves to the batch
3346
3620
  * results array (`[]` if `fn` queued nothing).
3347
3621
  *
@@ -3366,15 +3640,6 @@ class PlaidClient {
3366
3640
  return batch.submit();
3367
3641
  }
3368
3642
 
3369
- /**
3370
- * Authenticate and return a new client instance with token. This is the
3371
- * single auth entry point — there is no `client.login` resource.
3372
- * @param {string} baseUrl - The base URL for the API
3373
- * @param {string} userId - User ID for authentication
3374
- * @param {string} password - Password for authentication
3375
- * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
3376
- * @returns {Promise<PlaidClient>} - Authenticated client instance
3377
- */
3378
3643
  /**
3379
3644
  * Liveness, version and database size, with NO authentication and no client
3380
3645
  * instance — for a launcher or status page checking whether a server is up
@@ -3469,6 +3734,15 @@ class PlaidClient {
3469
3734
  };
3470
3735
  }
3471
3736
 
3737
+ /**
3738
+ * Authenticate and return a new client instance with token. This is the
3739
+ * single auth entry point — there is no `client.login` resource.
3740
+ * @param {string} baseUrl - The base URL for the API
3741
+ * @param {string} userId - User ID for authentication
3742
+ * @param {string} password - Password for authentication
3743
+ * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
3744
+ * @returns {Promise<PlaidClient>} - Authenticated client instance
3745
+ */
3472
3746
  static async login(baseUrl, userId, password, options = {}) {
3473
3747
  baseUrl = baseUrl.replace(/\/$/, "");
3474
3748
  const url = `${baseUrl}/api/v1/login`;
@@ -3541,6 +3815,9 @@ export {
3541
3815
  // request, and `critical()` holds that off around writes. See ./services.js
3542
3816
  // and the manual, "Stopping a request".
3543
3817
  export { ServiceCancelled, createCancelScope } from "./services.js";
3818
+ // Who asked for a service run, named in its History label and in what it
3819
+ // stores. See ./requester.js.
3820
+ export { REQUESTED_BY, makeRequester, requesterOf } from "./requester.js";
3544
3821
  // Document locks: `client.documents.locked()` holds one for a block of work and
3545
3822
  // renews it while the block runs. DocumentLockLost is what a write throws once
3546
3823
  // a renewal has failed. See ./documentLock.js.
@@ -3568,7 +3845,6 @@ export {
3568
3845
  needsReview,
3569
3846
  verifyOnEdit,
3570
3847
  contributeOnEdit,
3571
- mergeMetadata,
3572
3848
  serviceSource,
3573
3849
  userSource,
3574
3850
  REVIEW_KEY,
@@ -3579,4 +3855,15 @@ export {
3579
3855
  withReviewedUser,
3580
3856
  writerPolicy,
3581
3857
  } from "./provenance.js";
3858
+ // Metadata ops: the body of a metadata PATCH and of a bulk update entry's
3859
+ // `metadata`. See ./metadataOps.js.
3860
+ export {
3861
+ metadataOps,
3862
+ applyMetadataOps,
3863
+ mergeMetadata,
3864
+ isReservedMetadataKey,
3865
+ } from "./metadataOps.js";
3866
+ // The ids a create or bulk create answered with, off the call or its batch
3867
+ // result. See ./created.js.
3868
+ export { createdId, createdIds } from "./created.js";
3582
3869
  export { MAX_BATCH_OPS };