@larc-iu/plaid-client 0.2.0-alpha.0 → 0.2.0-alpha.11

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
@@ -2,19 +2,46 @@
2
2
  * plaid-client - JavaScript client for the Plaid annotation API
3
3
  */
4
4
 
5
- import { transformRequest, transformResponse } from './transforms.js';
5
+ import { transformRequest, transformResponse } from "./transforms.js";
6
6
  import {
7
- makeRequest, extractDocumentVersions, parseErrorBody, makeHttpError,
8
- makeNetworkError, timeoutSignal, DEFAULT_TIMEOUT_MS,
9
- } from './http.js';
10
- import { listAll, listPage, iterPages } from './pagination.js';
11
- import { createSSEConnection } from './sse.js';
7
+ makeRequest,
8
+ extractDocumentVersions,
9
+ parseErrorBody,
10
+ makeHttpError,
11
+ makeNetworkError,
12
+ timeoutSignal,
13
+ retryWhileBusy,
14
+ DEFAULT_TIMEOUT_MS,
15
+ DEFAULT_BATCH_TIMEOUT_MS,
16
+ } from "./http.js";
17
+ import { listAll, listPage, iterPages } from "./pagination.js";
18
+ import { createSSEConnection } from "./sse.js";
12
19
  import {
13
20
  discoverServices,
14
21
  discardService,
15
22
  serve,
16
23
  requestService,
17
- } from './services.js';
24
+ attachServiceRequest,
25
+ cancelServiceRequest,
26
+ } from "./services.js";
27
+
28
+ // Helper: normalize a document-read `layers` filter to the wire's
29
+ // comma-separated form. Accepts an array of layer ids or a ready-made string;
30
+ // anything empty becomes undefined so no `?layers=` is sent at all.
31
+ function layersParam(layers) {
32
+ if (layers === undefined || layers === null) return undefined;
33
+ const joined = Array.isArray(layers) ? layers.join(",") : String(layers);
34
+ return joined.length > 0 ? joined : undefined;
35
+ }
36
+
37
+ // Helper: normalize an audit `opTypes` filter to the wire's comma-separated
38
+ // form. Accepts an array of op types or a ready-made string; anything empty
39
+ // becomes undefined so no `?op-types=` is sent at all.
40
+ function opTypesParam(opTypes) {
41
+ if (opTypes === undefined || opTypes === null) return undefined;
42
+ const joined = Array.isArray(opTypes) ? opTypes.join(",") : String(opTypes);
43
+ return joined.length > 0 ? joined : undefined;
44
+ }
18
45
 
19
46
  // Helper: build body object, filtering out undefined values
20
47
  function bodyOf(obj) {
@@ -25,6 +52,45 @@ function bodyOf(obj) {
25
52
  return result;
26
53
  }
27
54
 
55
+ // POST to an endpoint that takes no Authorization header (login, invite lookup
56
+ // and redemption). Deliberately does not go through PlaidClient._request: these
57
+ // are called before any client exists, which is the whole point.
58
+ async function anonymousPost(baseUrl, path, body, options = {}) {
59
+ const base = baseUrl.replace(/\/$/, "");
60
+ const url = `${base}${path}`;
61
+ try {
62
+ const fetchOptions = {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify(body),
66
+ };
67
+ const signal = timeoutSignal(
68
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS,
69
+ );
70
+ if (signal) fetchOptions.signal = signal;
71
+
72
+ const response = await fetch(url, fetchOptions);
73
+ if (!response.ok) {
74
+ throw makeHttpError(
75
+ response,
76
+ await parseErrorBody(response),
77
+ url,
78
+ "POST",
79
+ );
80
+ }
81
+ return transformResponse(await response.json());
82
+ } catch (error) {
83
+ if (error.status) throw error;
84
+ throw makeNetworkError(error, url, "POST");
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The server's cap on operations per batch request (plaid.rest-api.v1.batch).
90
+ * A batch queued past it is submitted as consecutive requests.
91
+ */
92
+ const MAX_BATCH_OPS = 1000;
93
+
28
94
  class PlaidClient {
29
95
  /**
30
96
  * Create a new PlaidClient instance
@@ -32,18 +98,33 @@ class PlaidClient {
32
98
  * @param {string} token - The authentication token
33
99
  * @param {object} [options] - Client options
34
100
  * @param {number} [options.timeout=30000] - Per-request timeout in ms (0 or null disables it)
101
+ * @param {number} [options.batchTimeout=180000] - Timeout for batch submissions in ms (0 or null disables it)
35
102
  */
36
103
  constructor(baseUrl, token, options = {}) {
37
- this.baseUrl = baseUrl.replace(/\/$/, '');
104
+ this.baseUrl = baseUrl.replace(/\/$/, "");
38
105
  this.token = token;
39
- this.timeout = options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS;
106
+ this.timeout =
107
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS;
108
+ // Batch submissions get a longer budget than a single request. Aborting a
109
+ // batch does NOT stop the server: it keeps running the transaction and
110
+ // holding the single SQLite write lock, so a client that gives up early
111
+ // just adds a retry on top of a write that is still in progress. Falls
112
+ // back to an explicit `timeout` when one was given.
113
+ this.batchTimeout =
114
+ options.batchTimeout !== undefined
115
+ ? options.batchTimeout
116
+ : options.timeout !== undefined
117
+ ? options.timeout
118
+ : DEFAULT_BATCH_TIMEOUT_MS;
40
119
  this.isBatching = false;
41
120
  this.batchOperations = [];
42
121
  this.documentVersions = {};
43
122
  this.strictModeDocumentId = null;
44
- // Ambient custom audit-log message applied to write requests; null = use
45
- // the server's auto-generated description. See setAuditMessage / withAuditMessage.
46
- this.auditMessage = null;
123
+ // The open logical operation (audit-log group), or null. While set, every
124
+ // write is stamped with `?group-id=` (+ `group-message`) so the audit log
125
+ // folds them into ONE expandable entry. See beginOperation / withOperation.
126
+ // Shape: { id, message, depth, written, refined }.
127
+ this.operationGroup = null;
47
128
  // Optional callback fired once (per client) when any request returns HTTP
48
129
  // 401 — i.e. the token is missing/expired/invalid. Apps use it to discard
49
130
  // the stored token and route back to login. See makeRequest in http.js.
@@ -60,8 +141,9 @@ class PlaidClient {
60
141
  * @param {any} [metadata] - Metadata for the link. Omit to leave unset; pass null to send JSON null.
61
142
  */
62
143
  create: (vocabItem, tokens, metadata, auditMessage) =>
63
- this._request('POST', '/api/v1/vocab-links', { auditMessage,
64
- body: bodyOf({ 'vocab-item': vocabItem, tokens, metadata }),
144
+ this._request("POST", "/api/v1/vocab-links", {
145
+ auditMessage,
146
+ body: bodyOf({ "vocab-item": vocabItem, tokens, metadata }),
65
147
  }),
66
148
  /**
67
149
  * Create multiple vocab links in a single operation. Entries may
@@ -71,28 +153,37 @@ class PlaidClient {
71
153
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
72
154
  */
73
155
  bulkCreate: (body, auditMessage) =>
74
- this._request('POST', '/api/v1/vocab-links/bulk', { auditMessage, body }),
156
+ this._request("POST", "/api/v1/vocab-links/bulk", {
157
+ auditMessage,
158
+ body,
159
+ }),
75
160
  /**
76
161
  * Delete multiple vocab links in a single operation. Provide an array of IDs.
77
162
  * @param {string[]} body - The vocab link IDs to delete
78
163
  */
79
164
  bulkDelete: (body, auditMessage) =>
80
- this._request('DELETE', '/api/v1/vocab-links/bulk', { auditMessage, body }),
165
+ this._request("DELETE", "/api/v1/vocab-links/bulk", {
166
+ auditMessage,
167
+ body,
168
+ }),
81
169
  /**
82
170
  * Replace all metadata for a vocab link. The entire metadata map is replaced - existing metadata keys not included in the request will be removed.
83
171
  * @param {string} id - The resource ID
84
172
  * @param {any} body - The request body
85
173
  */
86
174
  setMetadata: (id, body, auditMessage) =>
87
- this._request('PUT', `/api/v1/vocab-links/${id}/metadata`, { auditMessage,
88
- rawBody: body, skipResponseTransform: true,
175
+ this._request("PUT", `/api/v1/vocab-links/${id}/metadata`, {
176
+ auditMessage,
177
+ rawBody: body,
178
+ skipResponseTransform: true,
89
179
  }),
90
180
  /**
91
181
  * Remove all metadata from a vocab link.
92
182
  * @param {string} id - The resource ID
93
183
  */
94
184
  deleteMetadata: (id, auditMessage) =>
95
- this._request('DELETE', `/api/v1/vocab-links/${id}/metadata`, { auditMessage,
185
+ this._request("DELETE", `/api/v1/vocab-links/${id}/metadata`, {
186
+ auditMessage,
96
187
  skipResponseTransform: true,
97
188
  }),
98
189
  /**
@@ -101,8 +192,10 @@ class PlaidClient {
101
192
  * @param {any} body - The metadata patch
102
193
  */
103
194
  patchMetadata: (id, body, auditMessage) =>
104
- this._request('PATCH', `/api/v1/vocab-links/${id}/metadata`, { auditMessage,
105
- rawBody: body, skipResponseTransform: true,
195
+ this._request("PATCH", `/api/v1/vocab-links/${id}/metadata`, {
196
+ auditMessage,
197
+ rawBody: body,
198
+ skipResponseTransform: true,
106
199
  }),
107
200
  /**
108
201
  * Get a vocab link by ID
@@ -110,15 +203,15 @@ class PlaidClient {
110
203
  * @param {string} [asOf] - Temporal query timestamp
111
204
  */
112
205
  get: (id, asOf) =>
113
- this._request('GET', `/api/v1/vocab-links/${id}`, {
114
- queryParams: { 'as-of': asOf },
206
+ this._request("GET", `/api/v1/vocab-links/${id}`, {
207
+ queryParams: { "as-of": asOf },
115
208
  }),
116
209
  /**
117
210
  * Delete a vocab link
118
211
  * @param {string} id - The resource ID
119
212
  */
120
213
  delete: (id, auditMessage) =>
121
- this._request('DELETE', `/api/v1/vocab-links/${id}`, { auditMessage }),
214
+ this._request("DELETE", `/api/v1/vocab-links/${id}`, { auditMessage }),
122
215
  };
123
216
 
124
217
  this.vocabLayers = {
@@ -129,22 +222,23 @@ class PlaidClient {
129
222
  * @param {string} [asOf] - Temporal query timestamp
130
223
  */
131
224
  get: (id, includeItems, asOf) =>
132
- this._request('GET', `/api/v1/vocab-layers/${id}`, {
133
- queryParams: { 'include-items': includeItems, 'as-of': asOf },
225
+ this._request("GET", `/api/v1/vocab-layers/${id}`, {
226
+ queryParams: { "include-items": includeItems, "as-of": asOf },
134
227
  }),
135
228
  /**
136
229
  * Delete a vocab layer.
137
230
  * @param {string} id - The resource ID
138
231
  */
139
232
  delete: (id, auditMessage) =>
140
- this._request('DELETE', `/api/v1/vocab-layers/${id}`, { auditMessage }),
233
+ this._request("DELETE", `/api/v1/vocab-layers/${id}`, { auditMessage }),
141
234
  /**
142
235
  * Update a vocab layer's name.
143
236
  * @param {string} id - The resource ID
144
237
  * @param {string} name - The name
145
238
  */
146
239
  update: (id, name, auditMessage) =>
147
- this._request('PATCH', `/api/v1/vocab-layers/${id}`, { auditMessage,
240
+ this._request("PATCH", `/api/v1/vocab-layers/${id}`, {
241
+ auditMessage,
148
242
  body: bodyOf({ name }),
149
243
  }),
150
244
  /**
@@ -155,9 +249,11 @@ class PlaidClient {
155
249
  * @param {any} configValue - Configuration value to set
156
250
  */
157
251
  setConfig: (id, namespace, configKey, configValue, auditMessage) =>
158
- this._request('PUT', `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`, { auditMessage,
159
- rawBody: configValue, skipResponseTransform: true,
160
- }),
252
+ this._request(
253
+ "PUT",
254
+ `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`,
255
+ { auditMessage, rawBody: configValue, skipResponseTransform: true },
256
+ ),
161
257
  /**
162
258
  * Remove a configuration value for a layer.
163
259
  * @param {string} id - The resource ID
@@ -165,9 +261,11 @@ class PlaidClient {
165
261
  * @param {string} configKey - The config key
166
262
  */
167
263
  deleteConfig: (id, namespace, configKey, auditMessage) =>
168
- this._request('DELETE', `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`, { auditMessage,
169
- skipResponseTransform: true,
170
- }),
264
+ this._request(
265
+ "DELETE",
266
+ `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`,
267
+ { auditMessage, skipResponseTransform: true },
268
+ ),
171
269
  /**
172
270
  * List all vocab layers accessible to user. Transparently follows
173
271
  * pagination cursors and returns the full flat array.
@@ -175,7 +273,7 @@ class PlaidClient {
175
273
  * @param {string} [asOf] - Temporal query timestamp
176
274
  */
177
275
  list: (asOf) =>
178
- listAll(this, '/api/v1/vocab-layers', { query: { 'as-of': asOf } }),
276
+ listAll(this, "/api/v1/vocab-layers", { query: { "as-of": asOf } }),
179
277
  /**
180
278
  * Fetch a single page of vocab layers.
181
279
  * @param {object} [opts]
@@ -185,7 +283,11 @@ class PlaidClient {
185
283
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
186
284
  */
187
285
  listPage: ({ limit, cursor, asOf } = {}) =>
188
- listPage(this, '/api/v1/vocab-layers', { limit, cursor, query: { 'as-of': asOf } }),
286
+ listPage(this, "/api/v1/vocab-layers", {
287
+ limit,
288
+ cursor,
289
+ query: { "as-of": asOf },
290
+ }),
189
291
  /**
190
292
  * Async-iterate vocab layers page by page; yields each page's entries array.
191
293
  * @param {object} [opts]
@@ -195,13 +297,17 @@ class PlaidClient {
195
297
  * @returns {AsyncGenerator<Array>}
196
298
  */
197
299
  iterPages: ({ pageSize, asOf } = {}) =>
198
- iterPages(this, '/api/v1/vocab-layers', { pageSize, query: { 'as-of': asOf } }),
300
+ iterPages(this, "/api/v1/vocab-layers", {
301
+ pageSize,
302
+ query: { "as-of": asOf },
303
+ }),
199
304
  /**
200
305
  * Create a new vocab layer. Note: this also registers the user as a maintainer.
201
306
  * @param {string} name - The name
202
307
  */
203
308
  create: (name, auditMessage) =>
204
- this._request('POST', '/api/v1/vocab-layers', { auditMessage,
309
+ this._request("POST", "/api/v1/vocab-layers", {
310
+ auditMessage,
205
311
  body: bodyOf({ name }),
206
312
  }),
207
313
  /**
@@ -210,14 +316,22 @@ class PlaidClient {
210
316
  * @param {string} userId - The user ID
211
317
  */
212
318
  addMaintainer: (id, userId, auditMessage) =>
213
- this._request('POST', `/api/v1/vocab-layers/${id}/maintainers/${userId}`, { auditMessage }),
319
+ this._request(
320
+ "POST",
321
+ `/api/v1/vocab-layers/${id}/maintainers/${userId}`,
322
+ { auditMessage },
323
+ ),
214
324
  /**
215
325
  * Remove a user's maintainer privileges for this vocab layer.
216
326
  * @param {string} id - The resource ID
217
327
  * @param {string} userId - The user ID
218
328
  */
219
329
  removeMaintainer: (id, userId, auditMessage) =>
220
- this._request('DELETE', `/api/v1/vocab-layers/${id}/maintainers/${userId}`, { auditMessage }),
330
+ this._request(
331
+ "DELETE",
332
+ `/api/v1/vocab-layers/${id}/maintainers/${userId}`,
333
+ { auditMessage },
334
+ ),
221
335
  };
222
336
 
223
337
  this.relations = {
@@ -227,15 +341,18 @@ class PlaidClient {
227
341
  * @param {any} body - The request body
228
342
  */
229
343
  setMetadata: (relationId, body, auditMessage) =>
230
- this._request('PUT', `/api/v1/relations/${relationId}/metadata`, { auditMessage,
231
- rawBody: body, skipResponseTransform: true,
344
+ this._request("PUT", `/api/v1/relations/${relationId}/metadata`, {
345
+ auditMessage,
346
+ rawBody: body,
347
+ skipResponseTransform: true,
232
348
  }),
233
349
  /**
234
350
  * Remove all metadata from a relation.
235
351
  * @param {string} relationId - The relation ID
236
352
  */
237
353
  deleteMetadata: (relationId, auditMessage) =>
238
- this._request('DELETE', `/api/v1/relations/${relationId}/metadata`, { auditMessage,
354
+ this._request("DELETE", `/api/v1/relations/${relationId}/metadata`, {
355
+ auditMessage,
239
356
  skipResponseTransform: true,
240
357
  }),
241
358
  /**
@@ -244,8 +361,10 @@ class PlaidClient {
244
361
  * @param {any} body - The metadata patch
245
362
  */
246
363
  patchMetadata: (relationId, body, auditMessage) =>
247
- this._request('PATCH', `/api/v1/relations/${relationId}/metadata`, { auditMessage,
248
- rawBody: body, skipResponseTransform: true,
364
+ this._request("PATCH", `/api/v1/relations/${relationId}/metadata`, {
365
+ auditMessage,
366
+ rawBody: body,
367
+ skipResponseTransform: true,
249
368
  }),
250
369
  /**
251
370
  * Update the target span of a relation.
@@ -253,8 +372,9 @@ class PlaidClient {
253
372
  * @param {string} spanId - The span ID
254
373
  */
255
374
  setTarget: (relationId, spanId, auditMessage) =>
256
- this._request('PUT', `/api/v1/relations/${relationId}/target`, { auditMessage,
257
- body: bodyOf({ 'span-id': spanId }),
375
+ this._request("PUT", `/api/v1/relations/${relationId}/target`, {
376
+ auditMessage,
377
+ body: bodyOf({ "span-id": spanId }),
258
378
  }),
259
379
  /**
260
380
  * Get a relation by ID.
@@ -262,22 +382,25 @@ class PlaidClient {
262
382
  * @param {string} [asOf] - Temporal query timestamp
263
383
  */
264
384
  get: (relationId, asOf) =>
265
- this._request('GET', `/api/v1/relations/${relationId}`, {
266
- queryParams: { 'as-of': asOf },
385
+ this._request("GET", `/api/v1/relations/${relationId}`, {
386
+ queryParams: { "as-of": asOf },
267
387
  }),
268
388
  /**
269
389
  * Delete a relation.
270
390
  * @param {string} relationId - The relation ID
271
391
  */
272
392
  delete: (relationId, auditMessage) =>
273
- this._request('DELETE', `/api/v1/relations/${relationId}`, { auditMessage }),
393
+ this._request("DELETE", `/api/v1/relations/${relationId}`, {
394
+ auditMessage,
395
+ }),
274
396
  /**
275
397
  * Update a relation's value.
276
398
  * @param {string} relationId - The relation ID
277
399
  * @param {any} value - The value
278
400
  */
279
401
  update: (relationId, value, auditMessage) =>
280
- this._request('PATCH', `/api/v1/relations/${relationId}`, { auditMessage,
402
+ this._request("PATCH", `/api/v1/relations/${relationId}`, {
403
+ auditMessage,
281
404
  body: bodyOf({ value }),
282
405
  }),
283
406
  /**
@@ -286,8 +409,9 @@ class PlaidClient {
286
409
  * @param {string} spanId - The span ID
287
410
  */
288
411
  setSource: (relationId, spanId, auditMessage) =>
289
- this._request('PUT', `/api/v1/relations/${relationId}/source`, { auditMessage,
290
- body: bodyOf({ 'span-id': spanId }),
412
+ this._request("PUT", `/api/v1/relations/${relationId}/source`, {
413
+ auditMessage,
414
+ body: bodyOf({ "span-id": spanId }),
291
415
  }),
292
416
  /**
293
417
  * Create a new relation. A relation is a directed edge between two spans
@@ -300,8 +424,15 @@ class PlaidClient {
300
424
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
301
425
  */
302
426
  create: (layerId, sourceId, targetId, value, metadata, auditMessage) =>
303
- this._request('POST', '/api/v1/relations', { auditMessage,
304
- body: bodyOf({ 'layer-id': layerId, 'source-id': sourceId, 'target-id': targetId, value, metadata }),
427
+ this._request("POST", "/api/v1/relations", {
428
+ auditMessage,
429
+ body: bodyOf({
430
+ "layer-id": layerId,
431
+ "source-id": sourceId,
432
+ "target-id": targetId,
433
+ value,
434
+ metadata,
435
+ }),
305
436
  }),
306
437
  /**
307
438
  * Create multiple relations in a single operation.
@@ -309,13 +440,16 @@ class PlaidClient {
309
440
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
310
441
  */
311
442
  bulkCreate: (body, auditMessage) =>
312
- this._request('POST', '/api/v1/relations/bulk', { auditMessage, body }),
443
+ this._request("POST", "/api/v1/relations/bulk", { auditMessage, body }),
313
444
  /**
314
445
  * Delete multiple relations in a single operation. Provide an array of IDs.
315
446
  * @param {Array} body - The request body
316
447
  */
317
448
  bulkDelete: (body, auditMessage) =>
318
- this._request('DELETE', '/api/v1/relations/bulk', { auditMessage, body }),
449
+ this._request("DELETE", "/api/v1/relations/bulk", {
450
+ auditMessage,
451
+ body,
452
+ }),
319
453
  };
320
454
 
321
455
  this.spanLayers = {
@@ -326,10 +460,18 @@ class PlaidClient {
326
460
  * @param {string} configKey - The config key
327
461
  * @param {any} configValue - Configuration value to set
328
462
  */
329
- setConfig: (spanLayerId, namespace, configKey, configValue, auditMessage) =>
330
- this._request('PUT', `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`, { auditMessage,
331
- rawBody: configValue, skipResponseTransform: true,
332
- }),
463
+ setConfig: (
464
+ spanLayerId,
465
+ namespace,
466
+ configKey,
467
+ configValue,
468
+ auditMessage,
469
+ ) =>
470
+ this._request(
471
+ "PUT",
472
+ `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`,
473
+ { auditMessage, rawBody: configValue, skipResponseTransform: true },
474
+ ),
333
475
  /**
334
476
  * Remove a configuration value for a layer.
335
477
  * @param {string} spanLayerId - The span layer ID
@@ -337,31 +479,36 @@ class PlaidClient {
337
479
  * @param {string} configKey - The config key
338
480
  */
339
481
  deleteConfig: (spanLayerId, namespace, configKey, auditMessage) =>
340
- this._request('DELETE', `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`, { auditMessage,
341
- skipResponseTransform: true,
342
- }),
482
+ this._request(
483
+ "DELETE",
484
+ `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`,
485
+ { auditMessage, skipResponseTransform: true },
486
+ ),
343
487
  /**
344
488
  * Get a span layer by ID.
345
489
  * @param {string} spanLayerId - The span layer ID
346
490
  * @param {string} [asOf] - Temporal query timestamp
347
491
  */
348
492
  get: (spanLayerId, asOf) =>
349
- this._request('GET', `/api/v1/span-layers/${spanLayerId}`, {
350
- queryParams: { 'as-of': asOf },
493
+ this._request("GET", `/api/v1/span-layers/${spanLayerId}`, {
494
+ queryParams: { "as-of": asOf },
351
495
  }),
352
496
  /**
353
497
  * Delete a span layer.
354
498
  * @param {string} spanLayerId - The span layer ID
355
499
  */
356
500
  delete: (spanLayerId, auditMessage) =>
357
- this._request('DELETE', `/api/v1/span-layers/${spanLayerId}`, { auditMessage }),
501
+ this._request("DELETE", `/api/v1/span-layers/${spanLayerId}`, {
502
+ auditMessage,
503
+ }),
358
504
  /**
359
505
  * Update a span layer's name.
360
506
  * @param {string} spanLayerId - The span layer ID
361
507
  * @param {string} name - The name
362
508
  */
363
509
  update: (spanLayerId, name, auditMessage) =>
364
- this._request('PATCH', `/api/v1/span-layers/${spanLayerId}`, { auditMessage,
510
+ this._request("PATCH", `/api/v1/span-layers/${spanLayerId}`, {
511
+ auditMessage,
365
512
  body: bodyOf({ name }),
366
513
  }),
367
514
  /**
@@ -370,8 +517,9 @@ class PlaidClient {
370
517
  * @param {string} name - The name
371
518
  */
372
519
  create: (tokenLayerId, name, auditMessage) =>
373
- this._request('POST', '/api/v1/span-layers', { auditMessage,
374
- body: bodyOf({ 'token-layer-id': tokenLayerId, name }),
520
+ this._request("POST", "/api/v1/span-layers", {
521
+ auditMessage,
522
+ body: bodyOf({ "token-layer-id": tokenLayerId, name }),
375
523
  }),
376
524
  /**
377
525
  * Shift a span layer's display order.
@@ -379,7 +527,8 @@ class PlaidClient {
379
527
  * @param {string} direction - The direction ("up" or "down")
380
528
  */
381
529
  shift: (spanLayerId, direction, auditMessage) =>
382
- this._request('POST', `/api/v1/span-layers/${spanLayerId}/shift`, { auditMessage,
530
+ this._request("POST", `/api/v1/span-layers/${spanLayerId}/shift`, {
531
+ auditMessage,
383
532
  body: bodyOf({ direction }),
384
533
  }),
385
534
  };
@@ -391,7 +540,8 @@ class PlaidClient {
391
540
  * @param {Array} tokens - The tokens
392
541
  */
393
542
  setTokens: (spanId, tokens, auditMessage) =>
394
- this._request('PUT', `/api/v1/spans/${spanId}/tokens`, { auditMessage,
543
+ this._request("PUT", `/api/v1/spans/${spanId}/tokens`, {
544
+ auditMessage,
395
545
  body: bodyOf({ tokens }),
396
546
  }),
397
547
  /**
@@ -403,8 +553,14 @@ class PlaidClient {
403
553
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
404
554
  */
405
555
  create: (spanLayerId, tokens, value, metadata, auditMessage) =>
406
- this._request('POST', '/api/v1/spans', { auditMessage,
407
- body: bodyOf({ 'span-layer-id': spanLayerId, tokens, value, metadata }),
556
+ this._request("POST", "/api/v1/spans", {
557
+ auditMessage,
558
+ body: bodyOf({
559
+ "span-layer-id": spanLayerId,
560
+ tokens,
561
+ value,
562
+ metadata,
563
+ }),
408
564
  }),
409
565
  /**
410
566
  * Get a span by ID.
@@ -412,22 +568,23 @@ class PlaidClient {
412
568
  * @param {string} [asOf] - Temporal query timestamp
413
569
  */
414
570
  get: (spanId, asOf) =>
415
- this._request('GET', `/api/v1/spans/${spanId}`, {
416
- queryParams: { 'as-of': asOf },
571
+ this._request("GET", `/api/v1/spans/${spanId}`, {
572
+ queryParams: { "as-of": asOf },
417
573
  }),
418
574
  /**
419
575
  * Delete a span.
420
576
  * @param {string} spanId - The span ID
421
577
  */
422
578
  delete: (spanId, auditMessage) =>
423
- this._request('DELETE', `/api/v1/spans/${spanId}`, { auditMessage }),
579
+ this._request("DELETE", `/api/v1/spans/${spanId}`, { auditMessage }),
424
580
  /**
425
581
  * Update a span's value.
426
582
  * @param {string} spanId - The span ID
427
583
  * @param {any} value - The value
428
584
  */
429
585
  update: (spanId, value, auditMessage) =>
430
- this._request('PATCH', `/api/v1/spans/${spanId}`, { auditMessage,
586
+ this._request("PATCH", `/api/v1/spans/${spanId}`, {
587
+ auditMessage,
431
588
  body: bodyOf({ value }),
432
589
  }),
433
590
  /**
@@ -436,28 +593,31 @@ class PlaidClient {
436
593
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
437
594
  */
438
595
  bulkCreate: (body, auditMessage) =>
439
- this._request('POST', '/api/v1/spans/bulk', { auditMessage, body }),
596
+ this._request("POST", "/api/v1/spans/bulk", { auditMessage, body }),
440
597
  /**
441
598
  * Delete multiple spans in a single operation. Provide an array of IDs.
442
599
  * @param {Array} body - The request body
443
600
  */
444
601
  bulkDelete: (body, auditMessage) =>
445
- this._request('DELETE', '/api/v1/spans/bulk', { auditMessage, body }),
602
+ this._request("DELETE", "/api/v1/spans/bulk", { auditMessage, body }),
446
603
  /**
447
604
  * Replace all metadata for a span.
448
605
  * @param {string} spanId - The span ID
449
606
  * @param {any} body - The request body
450
607
  */
451
608
  setMetadata: (spanId, body, auditMessage) =>
452
- this._request('PUT', `/api/v1/spans/${spanId}/metadata`, { auditMessage,
453
- rawBody: body, skipResponseTransform: true,
609
+ this._request("PUT", `/api/v1/spans/${spanId}/metadata`, {
610
+ auditMessage,
611
+ rawBody: body,
612
+ skipResponseTransform: true,
454
613
  }),
455
614
  /**
456
615
  * Remove all metadata from a span.
457
616
  * @param {string} spanId - The span ID
458
617
  */
459
618
  deleteMetadata: (spanId, auditMessage) =>
460
- this._request('DELETE', `/api/v1/spans/${spanId}/metadata`, { auditMessage,
619
+ this._request("DELETE", `/api/v1/spans/${spanId}/metadata`, {
620
+ auditMessage,
461
621
  skipResponseTransform: true,
462
622
  }),
463
623
  /**
@@ -466,8 +626,10 @@ class PlaidClient {
466
626
  * @param {any} body - The metadata patch
467
627
  */
468
628
  patchMetadata: (spanId, body, auditMessage) =>
469
- this._request('PATCH', `/api/v1/spans/${spanId}/metadata`, { auditMessage,
470
- rawBody: body, skipResponseTransform: true,
629
+ this._request("PATCH", `/api/v1/spans/${spanId}/metadata`, {
630
+ auditMessage,
631
+ rawBody: body,
632
+ skipResponseTransform: true,
471
633
  }),
472
634
  };
473
635
 
@@ -478,8 +640,10 @@ class PlaidClient {
478
640
  * @param {Array} body - The request body
479
641
  */
480
642
  submit: (body, auditMessage) =>
481
- this._request('POST', '/api/v1/batch', { auditMessage,
482
- body, noBatch: true,
643
+ this._request("POST", "/api/v1/batch", {
644
+ auditMessage,
645
+ body,
646
+ noBatch: true,
483
647
  }),
484
648
  };
485
649
 
@@ -490,15 +654,18 @@ class PlaidClient {
490
654
  * @param {any} body - The request body
491
655
  */
492
656
  setMetadata: (textId, body, auditMessage) =>
493
- this._request('PUT', `/api/v1/texts/${textId}/metadata`, { auditMessage,
494
- rawBody: body, skipResponseTransform: true,
657
+ this._request("PUT", `/api/v1/texts/${textId}/metadata`, {
658
+ auditMessage,
659
+ rawBody: body,
660
+ skipResponseTransform: true,
495
661
  }),
496
662
  /**
497
663
  * Remove all metadata from a text.
498
664
  * @param {string} textId - The text ID
499
665
  */
500
666
  deleteMetadata: (textId, auditMessage) =>
501
- this._request('DELETE', `/api/v1/texts/${textId}/metadata`, { auditMessage,
667
+ this._request("DELETE", `/api/v1/texts/${textId}/metadata`, {
668
+ auditMessage,
502
669
  skipResponseTransform: true,
503
670
  }),
504
671
  /**
@@ -507,8 +674,10 @@ class PlaidClient {
507
674
  * @param {any} body - The metadata patch
508
675
  */
509
676
  patchMetadata: (textId, body, auditMessage) =>
510
- this._request('PATCH', `/api/v1/texts/${textId}/metadata`, { auditMessage,
511
- rawBody: body, skipResponseTransform: true,
677
+ this._request("PATCH", `/api/v1/texts/${textId}/metadata`, {
678
+ auditMessage,
679
+ rawBody: body,
680
+ skipResponseTransform: true,
512
681
  }),
513
682
  /**
514
683
  * Create a new text in a document's text layer. A text is a container for
@@ -519,8 +688,14 @@ class PlaidClient {
519
688
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
520
689
  */
521
690
  create: (textLayerId, documentId, body, metadata, auditMessage) =>
522
- this._request('POST', '/api/v1/texts', { auditMessage,
523
- body: bodyOf({ 'text-layer-id': textLayerId, 'document-id': documentId, body, metadata }),
691
+ this._request("POST", "/api/v1/texts", {
692
+ auditMessage,
693
+ body: bodyOf({
694
+ "text-layer-id": textLayerId,
695
+ "document-id": documentId,
696
+ body,
697
+ metadata,
698
+ }),
524
699
  }),
525
700
  /**
526
701
  * Get a text.
@@ -528,24 +703,30 @@ class PlaidClient {
528
703
  * @param {string} [asOf] - Temporal query timestamp
529
704
  */
530
705
  get: (textId, asOf) =>
531
- this._request('GET', `/api/v1/texts/${textId}`, {
532
- queryParams: { 'as-of': asOf },
706
+ this._request("GET", `/api/v1/texts/${textId}`, {
707
+ queryParams: { "as-of": asOf },
533
708
  }),
534
709
  /**
535
710
  * Delete a text and all dependent data.
536
711
  * @param {string} textId - The text ID
537
712
  */
538
713
  delete: (textId, auditMessage) =>
539
- this._request('DELETE', `/api/v1/texts/${textId}`, { auditMessage }),
714
+ this._request("DELETE", `/api/v1/texts/${textId}`, { auditMessage }),
540
715
  /**
541
716
  * Update a text's body. A diff is computed and token indices are updated
542
717
  * so that tokens remain intact. Alternatively, `body` can be a list of
543
- * edit directives.
718
+ * edit directives (code-point indices, applied in order):
719
+ * {type: 'delete', index, value: count}
720
+ * {type: 'insert', index, value: string}
721
+ * {type: 'replace', index, length, value: string} — unlike
722
+ * delete+insert, a token covering the whole range is resized to keep
723
+ * it (respell a word in place without losing its annotations).
544
724
  * @param {string} textId - The text ID
545
725
  * @param {any} body - The request body
546
726
  */
547
727
  update: (textId, body, auditMessage) =>
548
- this._request('PATCH', `/api/v1/texts/${textId}`, { auditMessage,
728
+ this._request("PATCH", `/api/v1/texts/${textId}`, {
729
+ auditMessage,
549
730
  body: bodyOf({ body }),
550
731
  }),
551
732
  };
@@ -556,42 +737,58 @@ class PlaidClient {
556
737
  * returns the full flat array. Admin-or-maintainer only.
557
738
  * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
558
739
  * @param {object} [opts]
559
- * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
740
+ * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
560
741
  * @param {string} [opts.asOf] - Temporal query timestamp
561
742
  */
562
743
  list: ({ q, asOf } = {}) =>
563
- listAll(this, '/api/v1/users', { query: { q, 'as-of': asOf } }),
744
+ listAll(this, "/api/v1/users", { query: { q, "as-of": asOf } }),
564
745
  /**
565
746
  * Fetch a single page of users (optionally filtered by `q`).
566
747
  * @param {object} [opts]
567
- * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
748
+ * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
568
749
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
569
750
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
570
751
  * @param {string} [opts.asOf] - Temporal query timestamp
571
752
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
572
753
  */
573
754
  listPage: ({ q, limit, cursor, asOf } = {}) =>
574
- listPage(this, '/api/v1/users', { limit, cursor, query: { q, 'as-of': asOf } }),
755
+ listPage(this, "/api/v1/users", {
756
+ limit,
757
+ cursor,
758
+ query: { q, "as-of": asOf },
759
+ }),
575
760
  /**
576
761
  * Async-iterate users page by page; yields each page's entries array.
577
762
  * @param {object} [opts]
578
- * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
763
+ * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
579
764
  * @param {number} [opts.pageSize] - Per-request page size
580
765
  * @param {string} [opts.asOf] - Temporal query timestamp
581
766
  * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
582
767
  * @returns {AsyncGenerator<Array>}
583
768
  */
584
769
  iterPages: ({ q, pageSize, asOf } = {}) =>
585
- iterPages(this, '/api/v1/users', { pageSize, query: { q, 'as-of': asOf } }),
770
+ iterPages(this, "/api/v1/users", {
771
+ pageSize,
772
+ query: { q, "as-of": asOf },
773
+ }),
586
774
  /**
587
- * Create a new user
588
- * @param {string} username - The username
775
+ * Create a new user.
776
+ * @param {string} email - The account's email address. It becomes the
777
+ * user's id and is what they log in with; it can never be changed.
589
778
  * @param {string} password - The password
590
779
  * @param {boolean} isAdmin - Whether the user is an admin
780
+ * @param {string} [displayName] - How the user is shown in the UI.
781
+ * Defaults to the local part of the email.
591
782
  */
592
- create: (username, password, isAdmin, auditMessage) =>
593
- this._request('POST', '/api/v1/users', { auditMessage,
594
- body: bodyOf({ username, password, 'is-admin': isAdmin }),
783
+ create: (email, password, isAdmin, displayName, auditMessage) =>
784
+ this._request("POST", "/api/v1/users", {
785
+ auditMessage,
786
+ body: bodyOf({
787
+ email,
788
+ password,
789
+ "is-admin": isAdmin,
790
+ "display-name": displayName,
791
+ }),
595
792
  }),
596
793
  /**
597
794
  * Get audit log for a user's actions. Transparently follows pagination
@@ -601,10 +798,19 @@ class PlaidClient {
601
798
  * @param {string} [startTime] - Start of time range
602
799
  * @param {string} [endTime] - End of time range
603
800
  * @param {string} [asOf] - Temporal query timestamp
801
+ * @param {string[]|string} [opTypes] - Only return operations of these
802
+ * types, spelled as in an entry's `op/type` (e.g.
803
+ * `['span-layer/create', 'span-layer/delete']`). An entry appears when
804
+ * one of its operations matches, carrying only the ones that did.
604
805
  */
605
- audit: (userId, startTime, endTime, asOf) =>
806
+ audit: (userId, startTime, endTime, asOf, opTypes) =>
606
807
  listAll(this, `/api/v1/users/${userId}/audit`, {
607
- query: { 'start-time': startTime, 'end-time': endTime, 'as-of': asOf },
808
+ query: {
809
+ "start-time": startTime,
810
+ "end-time": endTime,
811
+ "as-of": asOf,
812
+ "op-types": opTypesParam(opTypes),
813
+ },
608
814
  }),
609
815
  /**
610
816
  * Get a user by ID
@@ -612,8 +818,8 @@ class PlaidClient {
612
818
  * @param {string} [asOf] - Temporal query timestamp
613
819
  */
614
820
  get: (id, asOf) =>
615
- this._request('GET', `/api/v1/users/${id}`, {
616
- queryParams: { 'as-of': asOf },
821
+ this._request("GET", `/api/v1/users/${id}`, {
822
+ queryParams: { "as-of": asOf },
617
823
  }),
618
824
  /**
619
825
  * Deactivate a user. Users are never hard-deleted: deactivation
@@ -624,7 +830,7 @@ class PlaidClient {
624
830
  * @param {string} id - The resource ID
625
831
  */
626
832
  delete: (id, auditMessage) =>
627
- this._request('DELETE', `/api/v1/users/${id}`, { auditMessage }),
833
+ this._request("DELETE", `/api/v1/users/${id}`, { auditMessage }),
628
834
  /**
629
835
  * Reactivate a deactivated user, restoring their ability to log in.
630
836
  * Project memberships, vocab maintainerships, and API tokens removed
@@ -632,22 +838,148 @@ class PlaidClient {
632
838
  * @param {string} id - The resource ID
633
839
  */
634
840
  activate: (id, auditMessage) =>
635
- this._request('POST', `/api/v1/users/${id}/activate`, { auditMessage }),
841
+ this._request("POST", `/api/v1/users/${id}/activate`, { auditMessage }),
636
842
  /**
637
- * Modify a user. Admins may change the username, password, and admin
638
- * status of any user. All other users may only modify their own username
639
- * or password.
640
- * @param {string} id - The resource ID
843
+ * Modify a user. Admins may change the display name, password, and admin
844
+ * status of any user. All other users may only modify their own display
845
+ * name or password.
846
+ *
847
+ * A user's id is their email address and is fixed for the life of the
848
+ * account — it is what they log in with, so nothing can change it.
849
+ * @param {string} id - The resource ID (the user's email address)
641
850
  * @param {string} [password] - New password
642
- * @param {string} [username] - New username
851
+ * @param {string} [displayName] - New display name
643
852
  * @param {boolean} [isAdmin] - New admin status
644
853
  */
645
- update: (id, password, username, isAdmin, auditMessage) =>
646
- this._request('PATCH', `/api/v1/users/${id}`, { auditMessage,
647
- body: bodyOf({ password, username, 'is-admin': isAdmin }),
854
+ update: (id, password, displayName, isAdmin, auditMessage) =>
855
+ this._request("PATCH", `/api/v1/users/${id}`, {
856
+ auditMessage,
857
+ body: bodyOf({
858
+ password,
859
+ "display-name": displayName,
860
+ "is-admin": isAdmin,
861
+ }),
862
+ }),
863
+ /**
864
+ * Build a URL for a user's profile picture, suitable for use directly as
865
+ * an `<img>` src. An image element cannot send an Authorization header,
866
+ * so the session token rides in the query string, the same way document
867
+ * media does.
868
+ *
869
+ * Pass the user record's `avatarHash` as the second argument whenever you
870
+ * have it: the URL then addresses that exact picture, so the browser can
871
+ * cache it indefinitely and still pick up a replacement the moment the
872
+ * user changes it. Returns null when the user has no picture, so callers
873
+ * can fall back to initials without a wasted request.
874
+ * @param {string} id - The user ID
875
+ * @param {string} [avatarHash] - The user record's `avatarHash`
876
+ * @returns {string|null}
877
+ */
878
+ avatarUrl: (id, avatarHash) => {
879
+ if (avatarHash === null) return null;
880
+ const params = new URLSearchParams({ token: this.token });
881
+ if (avatarHash) params.set("v", avatarHash);
882
+ return `${this.baseUrl}/api/v1/users/${id}/avatar?${params}`;
883
+ },
884
+ /**
885
+ * Fetch a user's profile picture as raw bytes. Most callers want
886
+ * avatarUrl() instead. This one is for non-browser consumers.
887
+ * @param {string} id - The user ID
888
+ */
889
+ getAvatar: (id) =>
890
+ this._request("GET", `/api/v1/users/${id}/avatar`, {
891
+ noBatch: true,
892
+ binaryResponse: true,
893
+ }),
894
+ /**
895
+ * Upload a profile picture. Your own, or anyone's if you are an admin.
896
+ * The server center-crops to a square, scales to the configured edge
897
+ * length, and re-encodes, so no client-side resizing is needed. Accepts
898
+ * PNG, JPEG, WebP, and GIF. Resolves to the updated user record.
899
+ * @param {string} id - The user ID
900
+ * @param {File} file - The image to upload
901
+ */
902
+ setAvatar: (id, file, auditMessage) => {
903
+ const fd = new FormData();
904
+ fd.append("file", file);
905
+ return this._request("PUT", `/api/v1/users/${id}/avatar`, {
906
+ auditMessage,
907
+ body: fd,
908
+ formData: true,
909
+ noBatch: true,
910
+ });
911
+ },
912
+ /**
913
+ * Remove a profile picture. Your own, or anyone's if you are an admin.
914
+ * @param {string} id - The user ID
915
+ */
916
+ deleteAvatar: (id, auditMessage) =>
917
+ this._request("DELETE", `/api/v1/users/${id}/avatar`, {
918
+ auditMessage,
919
+ noBatch: true,
648
920
  }),
649
921
  };
650
922
 
923
+ this.userData = {
924
+ /**
925
+ * List a user's private data entries ({key, updatedAt}, plus value when
926
+ * includeValues). Owner or admin only.
927
+ * @param {string} userId
928
+ * @param {object} [opts]
929
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
930
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
931
+ */
932
+ list: (userId, { prefix, includeValues } = {}) =>
933
+ this._request("GET", `/api/v1/users/${userId}/data`, {
934
+ queryParams: { prefix, "include-values": includeValues },
935
+ }),
936
+ /**
937
+ * Read one private data entry ({key, updatedAt, value}); 404 if absent.
938
+ * @param {string} userId
939
+ * @param {string} key
940
+ */
941
+ get: (userId, key) =>
942
+ this._request(
943
+ "GET",
944
+ `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
945
+ ),
946
+ /**
947
+ * Create or replace one private data entry. `value` is any JSON (up to
948
+ * 1 MB), stored as sent. Not audited, not batchable.
949
+ *
950
+ * The server stores it verbatim, but this client recases object keys on
951
+ * the way out and back like any other body (`myKey` <-> `my-key`), so a
952
+ * value whose keys are camelCase round-trips unchanged while one keyed
953
+ * by arbitrary strings does not. Put such a map under a `metadata` key,
954
+ * which both clients pass through untouched.
955
+ * @param {string} userId
956
+ * @param {string} key
957
+ * @param {*} value
958
+ */
959
+ put: (userId, key, value) =>
960
+ this._request(
961
+ "PUT",
962
+ `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
963
+ {
964
+ body: value,
965
+ noBatch: true,
966
+ },
967
+ ),
968
+ /**
969
+ * Delete one private data entry; 404 if absent.
970
+ * @param {string} userId
971
+ * @param {string} key
972
+ */
973
+ delete: (userId, key) =>
974
+ this._request(
975
+ "DELETE",
976
+ `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
977
+ {
978
+ noBatch: true,
979
+ },
980
+ ),
981
+ };
982
+
651
983
  this.apiTokens = {
652
984
  /**
653
985
  * List a user's named API tokens. Never includes the signed token
@@ -656,8 +988,7 @@ class PlaidClient {
656
988
  * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
657
989
  * @param {string} userId - The user ID who owns the tokens
658
990
  */
659
- list: (userId) =>
660
- listAll(this, `/api/v1/users/${userId}/tokens`),
991
+ list: (userId) => listAll(this, `/api/v1/users/${userId}/tokens`),
661
992
  /**
662
993
  * Fetch a single page of a user's named API tokens.
663
994
  * @param {string} userId - The user ID who owns the tokens
@@ -688,7 +1019,8 @@ class PlaidClient {
688
1019
  * @returns {Promise<{id: string, name: string, token: string}>}
689
1020
  */
690
1021
  create: (userId, name, auditMessage) =>
691
- this._request('POST', `/api/v1/users/${userId}/tokens`, { auditMessage,
1022
+ this._request("POST", `/api/v1/users/${userId}/tokens`, {
1023
+ auditMessage,
692
1024
  body: bodyOf({ name }),
693
1025
  }),
694
1026
  /**
@@ -697,7 +1029,109 @@ class PlaidClient {
697
1029
  * @param {string} tokenId - The token ID to revoke
698
1030
  */
699
1031
  revoke: (userId, tokenId, auditMessage) =>
700
- this._request('DELETE', `/api/v1/users/${userId}/tokens/${tokenId}`, { auditMessage }),
1032
+ this._request("DELETE", `/api/v1/users/${userId}/tokens/${tokenId}`, {
1033
+ auditMessage,
1034
+ }),
1035
+ };
1036
+
1037
+ this.invites = {
1038
+ /**
1039
+ * List invites you minted, oldest first. With `projectId`, lists that
1040
+ * project's invites instead (including co-maintainers'), which requires
1041
+ * maintainer or admin on it. Never includes invite codes — the code is
1042
+ * returned once, by create(), and is not recoverable afterward.
1043
+ * Transparently follows pagination cursors and returns the full flat array.
1044
+ * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1045
+ * @param {object} [opts]
1046
+ * @param {string} [opts.projectId] - List this project's invites instead of your own
1047
+ */
1048
+ list: ({ projectId } = {}) =>
1049
+ listAll(this, "/api/v1/invites", {
1050
+ query: { "project-id": projectId },
1051
+ }),
1052
+ /**
1053
+ * Fetch a single page of invites.
1054
+ * @param {object} [opts]
1055
+ * @param {string} [opts.projectId] - List this project's invites instead of your own
1056
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1057
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1058
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1059
+ */
1060
+ listPage: ({ projectId, limit, cursor } = {}) =>
1061
+ listPage(this, "/api/v1/invites", {
1062
+ limit,
1063
+ cursor,
1064
+ query: { "project-id": projectId },
1065
+ }),
1066
+ /**
1067
+ * Async-iterate invites page by page; yields each page's entries array.
1068
+ * @param {object} [opts]
1069
+ * @param {string} [opts.projectId] - List this project's invites instead of your own
1070
+ * @param {number} [opts.pageSize] - Per-request page size
1071
+ * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
1072
+ * @returns {AsyncGenerator<Array>}
1073
+ */
1074
+ iterPages: ({ projectId, pageSize } = {}) =>
1075
+ iterPages(this, "/api/v1/invites", {
1076
+ pageSize,
1077
+ query: { "project-id": projectId },
1078
+ }),
1079
+ /**
1080
+ * Mint an invite. The returned `code` is shown ONLY here — it is never
1081
+ * stored and cannot be recovered, so build and hand off the link now.
1082
+ * Use PlaidClient.inviteUrl() to turn it into one.
1083
+ *
1084
+ * Admins may mint anything. A project maintainer may mint role grants on
1085
+ * projects they maintain, and nothing else: no admin grant, no grantless
1086
+ * invite, no password resets — so for a non-admin, projectId/projectRole
1087
+ * are required in practice (403 without them).
1088
+ *
1089
+ * EVERY option is optional; `create()` with no arguments mints a
1090
+ * single-use signup link granting nothing but an account. Two pairing
1091
+ * rules the server enforces with a 400: projectId and projectRole must be
1092
+ * given TOGETHER, and targetUserId may not be combined with projectId,
1093
+ * grantAdmin, or a maxUses above 1.
1094
+ * @param {object} [opts]
1095
+ * @param {string} [opts.projectId] - Project the redeemer joins (requires projectRole)
1096
+ * @param {string} [opts.projectRole] - "reader" | "writer" | "maintainer" (requires projectId)
1097
+ * @param {boolean} [opts.grantAdmin] - Make the new account a global admin (admin only)
1098
+ * @param {string} [opts.targetUserId] - Password reset for that user instead of a signup; admin only, single-use, grants nothing
1099
+ * @param {number} [opts.maxUses] - How many accounts this link may create (default 1)
1100
+ * @param {number} [opts.ttlDays] - Days until it expires (default 14, max 365)
1101
+ * @param {string} [opts.note] - Human label shown in your invite list
1102
+ * @returns {Promise<{id: string, code: string, kind: string, status: string}>}
1103
+ */
1104
+ create: (
1105
+ {
1106
+ projectId,
1107
+ projectRole,
1108
+ grantAdmin,
1109
+ targetUserId,
1110
+ maxUses,
1111
+ ttlDays,
1112
+ note,
1113
+ } = {},
1114
+ auditMessage,
1115
+ ) =>
1116
+ this._request("POST", "/api/v1/invites", {
1117
+ auditMessage,
1118
+ body: bodyOf({
1119
+ "project-id": projectId,
1120
+ "project-role": projectRole,
1121
+ "grant-admin": grantAdmin,
1122
+ "target-user-id": targetUserId,
1123
+ "max-uses": maxUses,
1124
+ "ttl-days": ttlDays,
1125
+ note,
1126
+ }),
1127
+ }),
1128
+ /**
1129
+ * Revoke an invite, killing the link immediately. Idempotent. Allowed for
1130
+ * the creator, an admin, or a maintainer of the invite's project.
1131
+ * @param {string} id - The invite ID
1132
+ */
1133
+ revoke: (id, auditMessage) =>
1134
+ this._request("DELETE", `/api/v1/invites/${id}`, { auditMessage }),
701
1135
  };
702
1136
 
703
1137
  this.tokenLayers = {
@@ -707,7 +1141,8 @@ class PlaidClient {
707
1141
  * @param {string} direction - The direction ("up" or "down")
708
1142
  */
709
1143
  shift: (tokenLayerId, direction, auditMessage) =>
710
- this._request('POST', `/api/v1/token-layers/${tokenLayerId}/shift`, { auditMessage,
1144
+ this._request("POST", `/api/v1/token-layers/${tokenLayerId}/shift`, {
1145
+ auditMessage,
711
1146
  body: bodyOf({ direction }),
712
1147
  }),
713
1148
  /**
@@ -717,9 +1152,21 @@ class PlaidClient {
717
1152
  * @param {string} [overlapMode] - Per-layer, immutable token invariant: "any" (default), "non-overlapping", or "partitioning". On partitioning layers, single token create/update/delete are rejected; use bulkCreate plus split/merge/shift.
718
1153
  * @param {string} [parentTokenLayerId] - Optional immutable parent token layer. Tokens in this layer must nest within a parent-layer token; the parent layer must be in the same text layer and be "non-overlapping" or "partitioning" (an "any" parent is rejected). A nested layer may be "any" or "non-overlapping" but not "partitioning" (partitioning is only for root layers), e.g. words (non-overlapping, parent=sentences) within sentences (partitioning).
719
1154
  */
720
- create: (textLayerId, name, overlapMode, parentTokenLayerId, auditMessage) =>
721
- this._request('POST', '/api/v1/token-layers', { auditMessage,
722
- body: bodyOf({ 'text-layer-id': textLayerId, name, 'overlap-mode': overlapMode, 'parent-token-layer-id': parentTokenLayerId }),
1155
+ create: (
1156
+ textLayerId,
1157
+ name,
1158
+ overlapMode,
1159
+ parentTokenLayerId,
1160
+ auditMessage,
1161
+ ) =>
1162
+ this._request("POST", "/api/v1/token-layers", {
1163
+ auditMessage,
1164
+ body: bodyOf({
1165
+ "text-layer-id": textLayerId,
1166
+ name,
1167
+ "overlap-mode": overlapMode,
1168
+ "parent-token-layer-id": parentTokenLayerId,
1169
+ }),
723
1170
  }),
724
1171
  /**
725
1172
  * Set a configuration value for a layer in an editor namespace.
@@ -728,10 +1175,18 @@ class PlaidClient {
728
1175
  * @param {string} configKey - The config key
729
1176
  * @param {any} configValue - Configuration value to set
730
1177
  */
731
- setConfig: (tokenLayerId, namespace, configKey, configValue, auditMessage) =>
732
- this._request('PUT', `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`, { auditMessage,
733
- rawBody: configValue, skipResponseTransform: true,
734
- }),
1178
+ setConfig: (
1179
+ tokenLayerId,
1180
+ namespace,
1181
+ configKey,
1182
+ configValue,
1183
+ auditMessage,
1184
+ ) =>
1185
+ this._request(
1186
+ "PUT",
1187
+ `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`,
1188
+ { auditMessage, rawBody: configValue, skipResponseTransform: true },
1189
+ ),
735
1190
  /**
736
1191
  * Remove a configuration value for a layer.
737
1192
  * @param {string} tokenLayerId - The token layer ID
@@ -739,31 +1194,36 @@ class PlaidClient {
739
1194
  * @param {string} configKey - The config key
740
1195
  */
741
1196
  deleteConfig: (tokenLayerId, namespace, configKey, auditMessage) =>
742
- this._request('DELETE', `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`, { auditMessage,
743
- skipResponseTransform: true,
744
- }),
1197
+ this._request(
1198
+ "DELETE",
1199
+ `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`,
1200
+ { auditMessage, skipResponseTransform: true },
1201
+ ),
745
1202
  /**
746
1203
  * Get a token layer by ID.
747
1204
  * @param {string} tokenLayerId - The token layer ID
748
1205
  * @param {string} [asOf] - Temporal query timestamp
749
1206
  */
750
1207
  get: (tokenLayerId, asOf) =>
751
- this._request('GET', `/api/v1/token-layers/${tokenLayerId}`, {
752
- queryParams: { 'as-of': asOf },
1208
+ this._request("GET", `/api/v1/token-layers/${tokenLayerId}`, {
1209
+ queryParams: { "as-of": asOf },
753
1210
  }),
754
1211
  /**
755
1212
  * Delete a token layer.
756
1213
  * @param {string} tokenLayerId - The token layer ID
757
1214
  */
758
1215
  delete: (tokenLayerId, auditMessage) =>
759
- this._request('DELETE', `/api/v1/token-layers/${tokenLayerId}`, { auditMessage }),
1216
+ this._request("DELETE", `/api/v1/token-layers/${tokenLayerId}`, {
1217
+ auditMessage,
1218
+ }),
760
1219
  /**
761
1220
  * Update a token layer's name.
762
1221
  * @param {string} tokenLayerId - The token layer ID
763
1222
  * @param {string} name - The name
764
1223
  */
765
1224
  update: (tokenLayerId, name, auditMessage) =>
766
- this._request('PATCH', `/api/v1/token-layers/${tokenLayerId}`, { auditMessage,
1225
+ this._request("PATCH", `/api/v1/token-layers/${tokenLayerId}`, {
1226
+ auditMessage,
767
1227
  body: bodyOf({ name }),
768
1228
  }),
769
1229
  };
@@ -775,29 +1235,33 @@ class PlaidClient {
775
1235
  * @param {string} [asOf] - Temporal query timestamp
776
1236
  */
777
1237
  checkLock: (documentId, asOf) =>
778
- this._request('GET', `/api/v1/documents/${documentId}/lock`, {
779
- queryParams: { 'as-of': asOf },
1238
+ this._request("GET", `/api/v1/documents/${documentId}/lock`, {
1239
+ queryParams: { "as-of": asOf },
780
1240
  }),
781
1241
  /**
782
1242
  * Acquire or refresh a document lock
783
1243
  * @param {string} documentId - The document ID
784
1244
  */
785
1245
  acquireLock: (documentId, auditMessage) =>
786
- this._request('POST', `/api/v1/documents/${documentId}/lock`, { auditMessage }),
1246
+ this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1247
+ auditMessage,
1248
+ }),
787
1249
  /**
788
1250
  * Release a document lock
789
1251
  * @param {string} documentId - The document ID
790
1252
  */
791
1253
  releaseLock: (documentId, auditMessage) =>
792
- this._request('DELETE', `/api/v1/documents/${documentId}/lock`, { auditMessage }),
1254
+ this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
1255
+ auditMessage,
1256
+ }),
793
1257
  /**
794
- * Get media file for a document
1258
+ * Get the media file for a document. Media is not versioned, so there is
1259
+ * no as-of form: the route refuses the parameter. Prefer the document's
1260
+ * own `mediaUrl`, which carries the file's version for caching.
795
1261
  * @param {string} documentId - The document ID
796
- * @param {string} [asOf] - Temporal query timestamp
797
1262
  */
798
- getMedia: (documentId, asOf) =>
799
- this._request('GET', `/api/v1/documents/${documentId}/media`, {
800
- queryParams: { 'as-of': asOf },
1263
+ getMedia: (documentId) =>
1264
+ this._request("GET", `/api/v1/documents/${documentId}/media`, {
801
1265
  noBatch: true,
802
1266
  binaryResponse: true,
803
1267
  }),
@@ -805,12 +1269,18 @@ class PlaidClient {
805
1269
  * Upload a media file for a document. Uses Apache Tika for content validation.
806
1270
  * @param {string} documentId - The document ID
807
1271
  * @param {File} file - The file to upload
1272
+ * @param {string} [auditMessage] - Custom audit-log message for this write
1273
+ * @param {object} [options] - `{ onProgress }`: called with `{ loaded, total }` (bytes) as the file goes up. In a browser the upload then travels by XMLHttpRequest, and the request timeout only fires when the upload stalls, not on total time.
808
1274
  */
809
- uploadMedia: (documentId, file, auditMessage) => {
1275
+ uploadMedia: (documentId, file, auditMessage, { onProgress } = {}) => {
810
1276
  const fd = new FormData();
811
- fd.append('file', file);
812
- return this._request('PUT', `/api/v1/documents/${documentId}/media`, { auditMessage,
813
- body: fd, formData: true, noBatch: true,
1277
+ fd.append("file", file);
1278
+ return this._request("PUT", `/api/v1/documents/${documentId}/media`, {
1279
+ auditMessage,
1280
+ body: fd,
1281
+ formData: true,
1282
+ noBatch: true,
1283
+ onUploadProgress: onProgress,
814
1284
  });
815
1285
  },
816
1286
  /**
@@ -818,7 +1288,8 @@ class PlaidClient {
818
1288
  * @param {string} documentId - The document ID
819
1289
  */
820
1290
  deleteMedia: (documentId, auditMessage) =>
821
- this._request('DELETE', `/api/v1/documents/${documentId}/media`, { auditMessage,
1291
+ this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
1292
+ auditMessage,
822
1293
  noBatch: true,
823
1294
  }),
824
1295
  /**
@@ -827,15 +1298,18 @@ class PlaidClient {
827
1298
  * @param {any} body - The request body
828
1299
  */
829
1300
  setMetadata: (documentId, body, auditMessage) =>
830
- this._request('PUT', `/api/v1/documents/${documentId}/metadata`, { auditMessage,
831
- rawBody: body, skipResponseTransform: true,
1301
+ this._request("PUT", `/api/v1/documents/${documentId}/metadata`, {
1302
+ auditMessage,
1303
+ rawBody: body,
1304
+ skipResponseTransform: true,
832
1305
  }),
833
1306
  /**
834
1307
  * Remove all metadata from a document.
835
1308
  * @param {string} documentId - The document ID
836
1309
  */
837
1310
  deleteMetadata: (documentId, auditMessage) =>
838
- this._request('DELETE', `/api/v1/documents/${documentId}/metadata`, { auditMessage,
1311
+ this._request("DELETE", `/api/v1/documents/${documentId}/metadata`, {
1312
+ auditMessage,
839
1313
  skipResponseTransform: true,
840
1314
  }),
841
1315
  /**
@@ -844,8 +1318,10 @@ class PlaidClient {
844
1318
  * @param {any} body - The metadata patch
845
1319
  */
846
1320
  patchMetadata: (documentId, body, auditMessage) =>
847
- this._request('PATCH', `/api/v1/documents/${documentId}/metadata`, { auditMessage,
848
- rawBody: body, skipResponseTransform: true,
1321
+ this._request("PATCH", `/api/v1/documents/${documentId}/metadata`, {
1322
+ auditMessage,
1323
+ rawBody: body,
1324
+ skipResponseTransform: true,
849
1325
  }),
850
1326
  /**
851
1327
  * Get audit log for a document. Transparently follows pagination cursors
@@ -855,34 +1331,78 @@ class PlaidClient {
855
1331
  * @param {string} [startTime] - Start of time range
856
1332
  * @param {string} [endTime] - End of time range
857
1333
  * @param {string} [asOf] - Temporal query timestamp
1334
+ * @param {string[]|string} [opTypes] - Only return operations of these
1335
+ * types, spelled as in an entry's `op/type` (e.g.
1336
+ * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1337
+ * one of its operations matches, carrying only the ones that did.
858
1338
  */
859
- audit: (documentId, startTime, endTime, asOf) =>
1339
+ audit: (documentId, startTime, endTime, asOf, opTypes) =>
860
1340
  listAll(this, `/api/v1/documents/${documentId}/audit`, {
861
- query: { 'start-time': startTime, 'end-time': endTime, 'as-of': asOf },
1341
+ query: {
1342
+ "start-time": startTime,
1343
+ "end-time": endTime,
1344
+ "as-of": asOf,
1345
+ "op-types": opTypesParam(opTypes),
1346
+ },
1347
+ }),
1348
+ /**
1349
+ * Restore a document to its state at an earlier time, as one
1350
+ * operation: what was deleted since then comes back under its original
1351
+ * id, what was added since is removed, and what changed is set back,
1352
+ * across every layer. A layer deleted since then, or a vocabulary entry
1353
+ * that no longer exists, is skipped and reported under `skipped`.
1354
+ * Resolves to a summary of the changes. Maintainers only.
1355
+ * @param {string} documentId - The document ID
1356
+ * @param {string} asOf - The moment to go back to (ISO-8601 instant),
1357
+ * typically a history entry's `endTime`
1358
+ * @param {object} [options] - `{ dryRun }`: with `dryRun` true nothing
1359
+ * is written and the summary says what would change
1360
+ * @param {string} [auditMessage] - Custom audit message for this operation
1361
+ */
1362
+ restore: (documentId, asOf, { dryRun } = {}, auditMessage) =>
1363
+ this._request("POST", `/api/v1/documents/${documentId}/restore`, {
1364
+ queryParams: { "as-of": asOf, "dry-run": dryRun ? "true" : undefined },
1365
+ auditMessage,
862
1366
  }),
863
1367
  /**
864
1368
  * Get a document. Set `includeBody` to true to include all data.
1369
+ *
1370
+ * `layers` narrows a body read to the layers you name (ids of any kind:
1371
+ * text, token, span, or relation). A layer comes back when it is named
1372
+ * or is an ancestor of a named layer, and carries its own
1373
+ * texts/tokens/spans/relations/vocabs only when it is itself named — so
1374
+ * name the text layer too if you also want the text body. An id that is
1375
+ * not a layer of this document's project is an error, not a quietly
1376
+ * smaller response. Requires `includeBody`.
865
1377
  * @param {string} documentId - The document ID
866
1378
  * @param {boolean} [includeBody] - Include document body data
867
1379
  * @param {string} [asOf] - Temporal query timestamp
1380
+ * @param {string[]|string} [layers] - Layer ids to restrict a body read to
868
1381
  */
869
- get: (documentId, includeBody, asOf) =>
870
- this._request('GET', `/api/v1/documents/${documentId}`, {
871
- queryParams: { 'include-body': includeBody, 'as-of': asOf },
1382
+ get: (documentId, includeBody, asOf, layers) =>
1383
+ this._request("GET", `/api/v1/documents/${documentId}`, {
1384
+ queryParams: {
1385
+ "include-body": includeBody,
1386
+ "as-of": asOf,
1387
+ layers: layersParam(layers),
1388
+ },
872
1389
  }),
873
1390
  /**
874
1391
  * Delete a document and all data contained.
875
1392
  * @param {string} documentId - The document ID
876
1393
  */
877
1394
  delete: (documentId, auditMessage) =>
878
- this._request('DELETE', `/api/v1/documents/${documentId}`, { auditMessage }),
1395
+ this._request("DELETE", `/api/v1/documents/${documentId}`, {
1396
+ auditMessage,
1397
+ }),
879
1398
  /**
880
1399
  * Update a document's name.
881
1400
  * @param {string} documentId - The document ID
882
1401
  * @param {string} name - The name
883
1402
  */
884
1403
  update: (documentId, name, auditMessage) =>
885
- this._request('PATCH', `/api/v1/documents/${documentId}`, { auditMessage,
1404
+ this._request("PATCH", `/api/v1/documents/${documentId}`, {
1405
+ auditMessage,
886
1406
  body: bodyOf({ name }),
887
1407
  }),
888
1408
  /**
@@ -892,8 +1412,9 @@ class PlaidClient {
892
1412
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
893
1413
  */
894
1414
  create: (projectId, name, metadata, auditMessage) =>
895
- this._request('POST', '/api/v1/documents', { auditMessage,
896
- body: bodyOf({ 'project-id': projectId, name, metadata }),
1415
+ this._request("POST", "/api/v1/documents", {
1416
+ auditMessage,
1417
+ body: bodyOf({ "project-id": projectId, name, metadata }),
897
1418
  }),
898
1419
  };
899
1420
 
@@ -904,28 +1425,36 @@ class PlaidClient {
904
1425
  * @param {string} userId - The user ID
905
1426
  */
906
1427
  addWriter: (id, userId, auditMessage) =>
907
- this._request('POST', `/api/v1/projects/${id}/writers/${userId}`, { auditMessage }),
1428
+ this._request("POST", `/api/v1/projects/${id}/writers/${userId}`, {
1429
+ auditMessage,
1430
+ }),
908
1431
  /**
909
1432
  * Remove a user's writer privileges for this project.
910
1433
  * @param {string} id - The resource ID
911
1434
  * @param {string} userId - The user ID
912
1435
  */
913
1436
  removeWriter: (id, userId, auditMessage) =>
914
- this._request('DELETE', `/api/v1/projects/${id}/writers/${userId}`, { auditMessage }),
1437
+ this._request("DELETE", `/api/v1/projects/${id}/writers/${userId}`, {
1438
+ auditMessage,
1439
+ }),
915
1440
  /**
916
1441
  * Set a user's access level to read-only for this project.
917
1442
  * @param {string} id - The resource ID
918
1443
  * @param {string} userId - The user ID
919
1444
  */
920
1445
  addReader: (id, userId, auditMessage) =>
921
- this._request('POST', `/api/v1/projects/${id}/readers/${userId}`, { auditMessage }),
1446
+ this._request("POST", `/api/v1/projects/${id}/readers/${userId}`, {
1447
+ auditMessage,
1448
+ }),
922
1449
  /**
923
1450
  * Remove a user's reader privileges for this project.
924
1451
  * @param {string} id - The resource ID
925
1452
  * @param {string} userId - The user ID
926
1453
  */
927
1454
  removeReader: (id, userId, auditMessage) =>
928
- this._request('DELETE', `/api/v1/projects/${id}/readers/${userId}`, { auditMessage }),
1455
+ this._request("DELETE", `/api/v1/projects/${id}/readers/${userId}`, {
1456
+ auditMessage,
1457
+ }),
929
1458
  /**
930
1459
  * Set a configuration value for a project in an editor namespace.
931
1460
  * @param {string} id - The resource ID
@@ -934,9 +1463,11 @@ class PlaidClient {
934
1463
  * @param {any} configValue - Configuration value to set
935
1464
  */
936
1465
  setConfig: (id, namespace, configKey, configValue, auditMessage) =>
937
- this._request('PUT', `/api/v1/projects/${id}/config/${namespace}/${configKey}`, { auditMessage,
938
- rawBody: configValue, skipResponseTransform: true,
939
- }),
1466
+ this._request(
1467
+ "PUT",
1468
+ `/api/v1/projects/${id}/config/${namespace}/${configKey}`,
1469
+ { auditMessage, rawBody: configValue, skipResponseTransform: true },
1470
+ ),
940
1471
  /**
941
1472
  * Remove a configuration value for a project.
942
1473
  * @param {string} id - The resource ID
@@ -944,23 +1475,31 @@ class PlaidClient {
944
1475
  * @param {string} configKey - The config key
945
1476
  */
946
1477
  deleteConfig: (id, namespace, configKey, auditMessage) =>
947
- this._request('DELETE', `/api/v1/projects/${id}/config/${namespace}/${configKey}`, { auditMessage,
948
- skipResponseTransform: true,
949
- }),
1478
+ this._request(
1479
+ "DELETE",
1480
+ `/api/v1/projects/${id}/config/${namespace}/${configKey}`,
1481
+ { auditMessage, skipResponseTransform: true },
1482
+ ),
950
1483
  /**
951
1484
  * Assign a user as a maintainer for this project.
952
1485
  * @param {string} id - The resource ID
953
1486
  * @param {string} userId - The user ID
954
1487
  */
955
1488
  addMaintainer: (id, userId, auditMessage) =>
956
- this._request('POST', `/api/v1/projects/${id}/maintainers/${userId}`, { auditMessage }),
1489
+ this._request("POST", `/api/v1/projects/${id}/maintainers/${userId}`, {
1490
+ auditMessage,
1491
+ }),
957
1492
  /**
958
1493
  * Remove a user's maintainer privileges for this project.
959
1494
  * @param {string} id - The resource ID
960
1495
  * @param {string} userId - The user ID
961
1496
  */
962
1497
  removeMaintainer: (id, userId, auditMessage) =>
963
- this._request('DELETE', `/api/v1/projects/${id}/maintainers/${userId}`, { auditMessage }),
1498
+ this._request(
1499
+ "DELETE",
1500
+ `/api/v1/projects/${id}/maintainers/${userId}`,
1501
+ { auditMessage },
1502
+ ),
964
1503
  /**
965
1504
  * Get audit log for a project. Transparently follows pagination cursors
966
1505
  * and returns the full flat array.
@@ -969,10 +1508,33 @@ class PlaidClient {
969
1508
  * @param {string} [startTime] - Start of time range
970
1509
  * @param {string} [endTime] - End of time range
971
1510
  * @param {string} [asOf] - Temporal query timestamp
1511
+ * @param {string[]|string} [opTypes] - Only return operations of these
1512
+ * types, spelled as in an entry's `op/type` (e.g.
1513
+ * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1514
+ * one of its operations matches, carrying only the ones that did.
972
1515
  */
973
- audit: (projectId, startTime, endTime, asOf) =>
1516
+ audit: (projectId, startTime, endTime, asOf, opTypes) =>
974
1517
  listAll(this, `/api/v1/projects/${projectId}/audit`, {
975
- query: { 'start-time': startTime, 'end-time': endTime, 'as-of': asOf },
1518
+ query: {
1519
+ "start-time": startTime,
1520
+ "end-time": endTime,
1521
+ "as-of": asOf,
1522
+ "op-types": opTypesParam(opTypes),
1523
+ },
1524
+ }),
1525
+ /**
1526
+ * When the calling user last wrote to each document in a project, as a
1527
+ * `{documentId: timestamp}` map. Documents they have never written to
1528
+ * are absent. One request covers a whole document list.
1529
+ *
1530
+ * The response is NOT key-transformed: its keys are document ids, and
1531
+ * camelCasing would mangle the hyphens in a UUID.
1532
+ * @param {string} projectId - The project ID
1533
+ * @returns {Promise<Object<string, string>>}
1534
+ */
1535
+ myLastEdits: (projectId) =>
1536
+ this._request("GET", `/api/v1/projects/${projectId}/audit/last-edits`, {
1537
+ skipResponseTransform: true,
976
1538
  }),
977
1539
  /**
978
1540
  * Link a vocabulary to a project.
@@ -980,14 +1542,18 @@ class PlaidClient {
980
1542
  * @param {string} vocabId - The vocab layer ID
981
1543
  */
982
1544
  linkVocab: (id, vocabId, auditMessage) =>
983
- this._request('POST', `/api/v1/projects/${id}/vocabs/${vocabId}`, { auditMessage }),
1545
+ this._request("POST", `/api/v1/projects/${id}/vocabs/${vocabId}`, {
1546
+ auditMessage,
1547
+ }),
984
1548
  /**
985
1549
  * Unlink a vocabulary from a project.
986
1550
  * @param {string} id - The resource ID
987
1551
  * @param {string} vocabId - The vocab layer ID
988
1552
  */
989
1553
  unlinkVocab: (id, vocabId, auditMessage) =>
990
- this._request('DELETE', `/api/v1/projects/${id}/vocabs/${vocabId}`, { auditMessage }),
1554
+ this._request("DELETE", `/api/v1/projects/${id}/vocabs/${vocabId}`, {
1555
+ auditMessage,
1556
+ }),
991
1557
  /**
992
1558
  * Get a project by ID. To fetch the project's documents, use
993
1559
  * listDocuments(id) — the include-documents flag has been removed.
@@ -995,8 +1561,8 @@ class PlaidClient {
995
1561
  * @param {string} [asOf] - Temporal query timestamp
996
1562
  */
997
1563
  get: (id, asOf) =>
998
- this._request('GET', `/api/v1/projects/${id}`, {
999
- queryParams: { 'as-of': asOf },
1564
+ this._request("GET", `/api/v1/projects/${id}`, {
1565
+ queryParams: { "as-of": asOf },
1000
1566
  }),
1001
1567
  /**
1002
1568
  * List all documents in a project. Transparently follows pagination
@@ -1007,8 +1573,7 @@ class PlaidClient {
1007
1573
  * server rejects `?as-of=` on the documents-list route with a 400.
1008
1574
  * @param {string} id - The project ID
1009
1575
  */
1010
- listDocuments: (id) =>
1011
- listAll(this, `/api/v1/projects/${id}/documents`),
1576
+ listDocuments: (id) => listAll(this, `/api/v1/projects/${id}/documents`),
1012
1577
  /**
1013
1578
  * Fetch a single page of a project's documents.
1014
1579
  *
@@ -1048,14 +1613,18 @@ class PlaidClient {
1048
1613
  * @param {number} [options.timeout=0] - Per-request timeout in ms (0/null disables)
1049
1614
  */
1050
1615
  delete: (id, auditMessage, { timeout = 0 } = {}) =>
1051
- this._request('DELETE', `/api/v1/projects/${id}`, { auditMessage, timeout }),
1616
+ this._request("DELETE", `/api/v1/projects/${id}`, {
1617
+ auditMessage,
1618
+ timeout,
1619
+ }),
1052
1620
  /**
1053
1621
  * Update a project's name.
1054
1622
  * @param {string} id - The resource ID
1055
1623
  * @param {string} name - The name
1056
1624
  */
1057
1625
  update: (id, name, auditMessage) =>
1058
- this._request('PATCH', `/api/v1/projects/${id}`, { auditMessage,
1626
+ this._request("PATCH", `/api/v1/projects/${id}`, {
1627
+ auditMessage,
1059
1628
  body: bodyOf({ name }),
1060
1629
  }),
1061
1630
  /**
@@ -1065,7 +1634,7 @@ class PlaidClient {
1065
1634
  * @param {string} [asOf] - Temporal query timestamp
1066
1635
  */
1067
1636
  list: (asOf) =>
1068
- listAll(this, '/api/v1/projects', { query: { 'as-of': asOf } }),
1637
+ listAll(this, "/api/v1/projects", { query: { "as-of": asOf } }),
1069
1638
  /**
1070
1639
  * Fetch a single page of projects.
1071
1640
  * @param {object} [opts]
@@ -1075,7 +1644,11 @@ class PlaidClient {
1075
1644
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1076
1645
  */
1077
1646
  listPage: ({ limit, cursor, asOf } = {}) =>
1078
- listPage(this, '/api/v1/projects', { limit, cursor, query: { 'as-of': asOf } }),
1647
+ listPage(this, "/api/v1/projects", {
1648
+ limit,
1649
+ cursor,
1650
+ query: { "as-of": asOf },
1651
+ }),
1079
1652
  /**
1080
1653
  * Async-iterate projects page by page; yields each page's entries array.
1081
1654
  * @param {object} [opts]
@@ -1085,13 +1658,17 @@ class PlaidClient {
1085
1658
  * @returns {AsyncGenerator<Array>}
1086
1659
  */
1087
1660
  iterPages: ({ pageSize, asOf } = {}) =>
1088
- iterPages(this, '/api/v1/projects', { pageSize, query: { 'as-of': asOf } }),
1661
+ iterPages(this, "/api/v1/projects", {
1662
+ pageSize,
1663
+ query: { "as-of": asOf },
1664
+ }),
1089
1665
  /**
1090
1666
  * Create a new project. Note: this also registers the user as a maintainer.
1091
1667
  * @param {string} name - The name
1092
1668
  */
1093
1669
  create: (name, auditMessage) =>
1094
- this._request('POST', '/api/v1/projects', { auditMessage,
1670
+ this._request("POST", "/api/v1/projects", {
1671
+ auditMessage,
1095
1672
  body: bodyOf({ name }),
1096
1673
  }),
1097
1674
  };
@@ -1104,10 +1681,18 @@ class PlaidClient {
1104
1681
  * @param {string} configKey - The config key
1105
1682
  * @param {any} configValue - Configuration value to set
1106
1683
  */
1107
- setConfig: (textLayerId, namespace, configKey, configValue, auditMessage) =>
1108
- this._request('PUT', `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1109
- rawBody: configValue, skipResponseTransform: true,
1110
- }),
1684
+ setConfig: (
1685
+ textLayerId,
1686
+ namespace,
1687
+ configKey,
1688
+ configValue,
1689
+ auditMessage,
1690
+ ) =>
1691
+ this._request(
1692
+ "PUT",
1693
+ `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`,
1694
+ { auditMessage, rawBody: configValue, skipResponseTransform: true },
1695
+ ),
1111
1696
  /**
1112
1697
  * Remove a configuration value for a layer.
1113
1698
  * @param {string} textLayerId - The text layer ID
@@ -1115,31 +1700,36 @@ class PlaidClient {
1115
1700
  * @param {string} configKey - The config key
1116
1701
  */
1117
1702
  deleteConfig: (textLayerId, namespace, configKey, auditMessage) =>
1118
- this._request('DELETE', `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1119
- skipResponseTransform: true,
1120
- }),
1703
+ this._request(
1704
+ "DELETE",
1705
+ `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`,
1706
+ { auditMessage, skipResponseTransform: true },
1707
+ ),
1121
1708
  /**
1122
1709
  * Get a text layer by ID.
1123
1710
  * @param {string} textLayerId - The text layer ID
1124
1711
  * @param {string} [asOf] - Temporal query timestamp
1125
1712
  */
1126
1713
  get: (textLayerId, asOf) =>
1127
- this._request('GET', `/api/v1/text-layers/${textLayerId}`, {
1128
- queryParams: { 'as-of': asOf },
1714
+ this._request("GET", `/api/v1/text-layers/${textLayerId}`, {
1715
+ queryParams: { "as-of": asOf },
1129
1716
  }),
1130
1717
  /**
1131
1718
  * Delete a text layer.
1132
1719
  * @param {string} textLayerId - The text layer ID
1133
1720
  */
1134
1721
  delete: (textLayerId, auditMessage) =>
1135
- this._request('DELETE', `/api/v1/text-layers/${textLayerId}`, { auditMessage }),
1722
+ this._request("DELETE", `/api/v1/text-layers/${textLayerId}`, {
1723
+ auditMessage,
1724
+ }),
1136
1725
  /**
1137
1726
  * Update a text layer's name.
1138
1727
  * @param {string} textLayerId - The text layer ID
1139
1728
  * @param {string} name - The name
1140
1729
  */
1141
1730
  update: (textLayerId, name, auditMessage) =>
1142
- this._request('PATCH', `/api/v1/text-layers/${textLayerId}`, { auditMessage,
1731
+ this._request("PATCH", `/api/v1/text-layers/${textLayerId}`, {
1732
+ auditMessage,
1143
1733
  body: bodyOf({ name }),
1144
1734
  }),
1145
1735
  /**
@@ -1148,7 +1738,8 @@ class PlaidClient {
1148
1738
  * @param {string} direction - The direction ("up" or "down")
1149
1739
  */
1150
1740
  shift: (textLayerId, direction, auditMessage) =>
1151
- this._request('POST', `/api/v1/text-layers/${textLayerId}/shift`, { auditMessage,
1741
+ this._request("POST", `/api/v1/text-layers/${textLayerId}/shift`, {
1742
+ auditMessage,
1152
1743
  body: bodyOf({ direction }),
1153
1744
  }),
1154
1745
  /**
@@ -1157,8 +1748,9 @@ class PlaidClient {
1157
1748
  * @param {string} name - The name
1158
1749
  */
1159
1750
  create: (projectId, name, auditMessage) =>
1160
- this._request('POST', '/api/v1/text-layers', { auditMessage,
1161
- body: bodyOf({ 'project-id': projectId, name }),
1751
+ this._request("POST", "/api/v1/text-layers", {
1752
+ auditMessage,
1753
+ body: bodyOf({ "project-id": projectId, name }),
1162
1754
  }),
1163
1755
  };
1164
1756
 
@@ -1169,15 +1761,18 @@ class PlaidClient {
1169
1761
  * @param {any} body - The request body
1170
1762
  */
1171
1763
  setMetadata: (id, body, auditMessage) =>
1172
- this._request('PUT', `/api/v1/vocab-items/${id}/metadata`, { auditMessage,
1173
- rawBody: body, skipResponseTransform: true,
1764
+ this._request("PUT", `/api/v1/vocab-items/${id}/metadata`, {
1765
+ auditMessage,
1766
+ rawBody: body,
1767
+ skipResponseTransform: true,
1174
1768
  }),
1175
1769
  /**
1176
1770
  * Remove all metadata from a vocab item.
1177
1771
  * @param {string} id - The resource ID
1178
1772
  */
1179
1773
  deleteMetadata: (id, auditMessage) =>
1180
- this._request('DELETE', `/api/v1/vocab-items/${id}/metadata`, { auditMessage,
1774
+ this._request("DELETE", `/api/v1/vocab-items/${id}/metadata`, {
1775
+ auditMessage,
1181
1776
  skipResponseTransform: true,
1182
1777
  }),
1183
1778
  /**
@@ -1186,8 +1781,10 @@ class PlaidClient {
1186
1781
  * @param {any} body - The metadata patch
1187
1782
  */
1188
1783
  patchMetadata: (id, body, auditMessage) =>
1189
- this._request('PATCH', `/api/v1/vocab-items/${id}/metadata`, { auditMessage,
1190
- rawBody: body, skipResponseTransform: true,
1784
+ this._request("PATCH", `/api/v1/vocab-items/${id}/metadata`, {
1785
+ auditMessage,
1786
+ rawBody: body,
1787
+ skipResponseTransform: true,
1191
1788
  }),
1192
1789
  /**
1193
1790
  * Create a new vocab item
@@ -1196,8 +1793,9 @@ class PlaidClient {
1196
1793
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
1197
1794
  */
1198
1795
  create: (vocabLayerId, form, metadata, auditMessage) =>
1199
- this._request('POST', '/api/v1/vocab-items', { auditMessage,
1200
- body: bodyOf({ 'vocab-layer-id': vocabLayerId, form, metadata }),
1796
+ this._request("POST", "/api/v1/vocab-items", {
1797
+ auditMessage,
1798
+ body: bodyOf({ "vocab-layer-id": vocabLayerId, form, metadata }),
1201
1799
  }),
1202
1800
  /**
1203
1801
  * Create multiple vocab items in a single operation. Entries may target
@@ -1205,36 +1803,43 @@ class PlaidClient {
1205
1803
  * @param {Array<{vocabLayerId: string, form: string, metadata?: any}>} body - The vocab items to create
1206
1804
  */
1207
1805
  bulkCreate: (body, auditMessage) =>
1208
- this._request('POST', '/api/v1/vocab-items/bulk', { auditMessage, body }),
1806
+ this._request("POST", "/api/v1/vocab-items/bulk", {
1807
+ auditMessage,
1808
+ body,
1809
+ }),
1209
1810
  /**
1210
1811
  * Delete multiple vocab items in a single operation. Each item's
1211
1812
  * descendant vocab links are deleted too. Provide an array of IDs.
1212
1813
  * @param {string[]} body - The vocab item IDs to delete
1213
1814
  */
1214
1815
  bulkDelete: (body, auditMessage) =>
1215
- this._request('DELETE', '/api/v1/vocab-items/bulk', { auditMessage, body }),
1816
+ this._request("DELETE", "/api/v1/vocab-items/bulk", {
1817
+ auditMessage,
1818
+ body,
1819
+ }),
1216
1820
  /**
1217
1821
  * Get a vocab item by ID
1218
1822
  * @param {string} id - The resource ID
1219
1823
  * @param {string} [asOf] - Temporal query timestamp
1220
1824
  */
1221
1825
  get: (id, asOf) =>
1222
- this._request('GET', `/api/v1/vocab-items/${id}`, {
1223
- queryParams: { 'as-of': asOf },
1826
+ this._request("GET", `/api/v1/vocab-items/${id}`, {
1827
+ queryParams: { "as-of": asOf },
1224
1828
  }),
1225
1829
  /**
1226
1830
  * Delete a vocab item
1227
1831
  * @param {string} id - The resource ID
1228
1832
  */
1229
1833
  delete: (id, auditMessage) =>
1230
- this._request('DELETE', `/api/v1/vocab-items/${id}`, { auditMessage }),
1834
+ this._request("DELETE", `/api/v1/vocab-items/${id}`, { auditMessage }),
1231
1835
  /**
1232
1836
  * Update a vocab item's form
1233
1837
  * @param {string} id - The resource ID
1234
1838
  * @param {string} form - The vocab item form
1235
1839
  */
1236
1840
  update: (id, form, auditMessage) =>
1237
- this._request('PATCH', `/api/v1/vocab-items/${id}`, { auditMessage,
1841
+ this._request("PATCH", `/api/v1/vocab-items/${id}`, {
1842
+ auditMessage,
1238
1843
  body: bodyOf({ form }),
1239
1844
  }),
1240
1845
  };
@@ -1246,17 +1851,20 @@ class PlaidClient {
1246
1851
  * @param {string} direction - The direction ("up" or "down")
1247
1852
  */
1248
1853
  shift: (relationLayerId, direction, auditMessage) =>
1249
- this._request('POST', `/api/v1/relation-layers/${relationLayerId}/shift`, { auditMessage,
1250
- body: bodyOf({ direction }),
1251
- }),
1854
+ this._request(
1855
+ "POST",
1856
+ `/api/v1/relation-layers/${relationLayerId}/shift`,
1857
+ { auditMessage, body: bodyOf({ direction }) },
1858
+ ),
1252
1859
  /**
1253
1860
  * Create a new relation layer.
1254
1861
  * @param {string} spanLayerId - The span layer ID
1255
1862
  * @param {string} name - The name
1256
1863
  */
1257
1864
  create: (spanLayerId, name, auditMessage) =>
1258
- this._request('POST', '/api/v1/relation-layers', { auditMessage,
1259
- body: bodyOf({ 'span-layer-id': spanLayerId, name }),
1865
+ this._request("POST", "/api/v1/relation-layers", {
1866
+ auditMessage,
1867
+ body: bodyOf({ "span-layer-id": spanLayerId, name }),
1260
1868
  }),
1261
1869
  /**
1262
1870
  * Set a configuration value for a layer in an editor namespace.
@@ -1265,10 +1873,18 @@ class PlaidClient {
1265
1873
  * @param {string} configKey - The config key
1266
1874
  * @param {any} configValue - Configuration value to set
1267
1875
  */
1268
- setConfig: (relationLayerId, namespace, configKey, configValue, auditMessage) =>
1269
- this._request('PUT', `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1270
- rawBody: configValue, skipResponseTransform: true,
1271
- }),
1876
+ setConfig: (
1877
+ relationLayerId,
1878
+ namespace,
1879
+ configKey,
1880
+ configValue,
1881
+ auditMessage,
1882
+ ) =>
1883
+ this._request(
1884
+ "PUT",
1885
+ `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`,
1886
+ { auditMessage, rawBody: configValue, skipResponseTransform: true },
1887
+ ),
1272
1888
  /**
1273
1889
  * Remove a configuration value for a layer.
1274
1890
  * @param {string} relationLayerId - The relation layer ID
@@ -1276,31 +1892,36 @@ class PlaidClient {
1276
1892
  * @param {string} configKey - The config key
1277
1893
  */
1278
1894
  deleteConfig: (relationLayerId, namespace, configKey, auditMessage) =>
1279
- this._request('DELETE', `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1280
- skipResponseTransform: true,
1281
- }),
1895
+ this._request(
1896
+ "DELETE",
1897
+ `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`,
1898
+ { auditMessage, skipResponseTransform: true },
1899
+ ),
1282
1900
  /**
1283
1901
  * Get a relation layer by ID.
1284
1902
  * @param {string} relationLayerId - The relation layer ID
1285
1903
  * @param {string} [asOf] - Temporal query timestamp
1286
1904
  */
1287
1905
  get: (relationLayerId, asOf) =>
1288
- this._request('GET', `/api/v1/relation-layers/${relationLayerId}`, {
1289
- queryParams: { 'as-of': asOf },
1906
+ this._request("GET", `/api/v1/relation-layers/${relationLayerId}`, {
1907
+ queryParams: { "as-of": asOf },
1290
1908
  }),
1291
1909
  /**
1292
1910
  * Delete a relation layer.
1293
1911
  * @param {string} relationLayerId - The relation layer ID
1294
1912
  */
1295
1913
  delete: (relationLayerId, auditMessage) =>
1296
- this._request('DELETE', `/api/v1/relation-layers/${relationLayerId}`, { auditMessage }),
1914
+ this._request("DELETE", `/api/v1/relation-layers/${relationLayerId}`, {
1915
+ auditMessage,
1916
+ }),
1297
1917
  /**
1298
1918
  * Update a relation layer's name.
1299
1919
  * @param {string} relationLayerId - The relation layer ID
1300
1920
  * @param {string} name - The name
1301
1921
  */
1302
1922
  update: (relationLayerId, name, auditMessage) =>
1303
- this._request('PATCH', `/api/v1/relation-layers/${relationLayerId}`, { auditMessage,
1923
+ this._request("PATCH", `/api/v1/relation-layers/${relationLayerId}`, {
1924
+ auditMessage,
1304
1925
  body: bodyOf({ name }),
1305
1926
  }),
1306
1927
  };
@@ -1324,9 +1945,25 @@ class PlaidClient {
1324
1945
  * @param {number} [precedence] - Ordering precedence
1325
1946
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
1326
1947
  */
1327
- create: (tokenLayerId, text, begin, end, precedence, metadata, auditMessage) =>
1328
- this._request('POST', '/api/v1/tokens', { auditMessage,
1329
- body: bodyOf({ 'token-layer-id': tokenLayerId, text, begin, end, precedence, metadata }),
1948
+ create: (
1949
+ tokenLayerId,
1950
+ text,
1951
+ begin,
1952
+ end,
1953
+ precedence,
1954
+ metadata,
1955
+ auditMessage,
1956
+ ) =>
1957
+ this._request("POST", "/api/v1/tokens", {
1958
+ auditMessage,
1959
+ body: bodyOf({
1960
+ "token-layer-id": tokenLayerId,
1961
+ text,
1962
+ begin,
1963
+ end,
1964
+ precedence,
1965
+ metadata,
1966
+ }),
1330
1967
  }),
1331
1968
  /**
1332
1969
  * Get a token.
@@ -1334,8 +1971,8 @@ class PlaidClient {
1334
1971
  * @param {string} [asOf] - Temporal query timestamp
1335
1972
  */
1336
1973
  get: (tokenId, asOf) =>
1337
- this._request('GET', `/api/v1/tokens/${tokenId}`, {
1338
- queryParams: { 'as-of': asOf },
1974
+ this._request("GET", `/api/v1/tokens/${tokenId}`, {
1975
+ queryParams: { "as-of": asOf },
1339
1976
  }),
1340
1977
  /**
1341
1978
  * Delete a token and remove it from any spans. If this causes a span to
@@ -1343,7 +1980,7 @@ class PlaidClient {
1343
1980
  * @param {string} tokenId - The token ID
1344
1981
  */
1345
1982
  delete: (tokenId, auditMessage) =>
1346
- this._request('DELETE', `/api/v1/tokens/${tokenId}`, { auditMessage }),
1983
+ this._request("DELETE", `/api/v1/tokens/${tokenId}`, { auditMessage }),
1347
1984
  /**
1348
1985
  * Update a token.
1349
1986
  * @param {string} tokenId - The token ID
@@ -1355,7 +1992,8 @@ class PlaidClient {
1355
1992
  * drops undefined, so the three cases map correctly to the server.
1356
1993
  */
1357
1994
  update: (tokenId, begin, end, precedence, auditMessage) =>
1358
- this._request('PATCH', `/api/v1/tokens/${tokenId}`, { auditMessage,
1995
+ this._request("PATCH", `/api/v1/tokens/${tokenId}`, {
1996
+ auditMessage,
1359
1997
  body: bodyOf({ begin, end, precedence }),
1360
1998
  }),
1361
1999
  /**
@@ -1364,13 +2002,13 @@ class PlaidClient {
1364
2002
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
1365
2003
  */
1366
2004
  bulkCreate: (body, auditMessage) =>
1367
- this._request('POST', '/api/v1/tokens/bulk', { auditMessage, body }),
2005
+ this._request("POST", "/api/v1/tokens/bulk", { auditMessage, body }),
1368
2006
  /**
1369
2007
  * Delete multiple tokens in a single operation. Provide an array of IDs.
1370
2008
  * @param {Array} body - The request body
1371
2009
  */
1372
2010
  bulkDelete: (body, auditMessage) =>
1373
- this._request('DELETE', '/api/v1/tokens/bulk', { auditMessage, body }),
2011
+ this._request("DELETE", "/api/v1/tokens/bulk", { auditMessage, body }),
1374
2012
  /**
1375
2013
  * Split a token at a Unicode code-point offset. The original token becomes the
1376
2014
  * left half (keeps its ID, spans, vocab-links); the new right token's ID is returned.
@@ -1378,7 +2016,8 @@ class PlaidClient {
1378
2016
  * @param {number} position - Code-point offset to split at (strictly between begin and end)
1379
2017
  */
1380
2018
  split: (tokenId, position, auditMessage) =>
1381
- this._request('POST', `/api/v1/tokens/${tokenId}/split`, { auditMessage,
2019
+ this._request("POST", `/api/v1/tokens/${tokenId}/split`, {
2020
+ auditMessage,
1382
2021
  body: bodyOf({ position }),
1383
2022
  }),
1384
2023
  /**
@@ -1390,8 +2029,9 @@ class PlaidClient {
1390
2029
  * @param {string} otherTokenId - The other token to merge in
1391
2030
  */
1392
2031
  merge: (tokenId, otherTokenId, auditMessage) =>
1393
- this._request('POST', `/api/v1/tokens/${tokenId}/merge`, { auditMessage,
1394
- body: bodyOf({ 'other-token-id': otherTokenId }),
2032
+ this._request("POST", `/api/v1/tokens/${tokenId}/merge`, {
2033
+ auditMessage,
2034
+ body: bodyOf({ "other-token-id": otherTokenId }),
1395
2035
  }),
1396
2036
  /**
1397
2037
  * Shift a token's boundary. On partitioning layers the adjacent token is
@@ -1402,7 +2042,8 @@ class PlaidClient {
1402
2042
  * @param {number} [end] - New end offset, exclusive (Unicode code points)
1403
2043
  */
1404
2044
  shift: (tokenId, begin, end, auditMessage) =>
1405
- this._request('POST', `/api/v1/tokens/${tokenId}/shift`, { auditMessage,
2045
+ this._request("POST", `/api/v1/tokens/${tokenId}/shift`, {
2046
+ auditMessage,
1406
2047
  body: bodyOf({ begin, end }),
1407
2048
  }),
1408
2049
  /**
@@ -1411,15 +2052,18 @@ class PlaidClient {
1411
2052
  * @param {any} body - The request body
1412
2053
  */
1413
2054
  setMetadata: (tokenId, body, auditMessage) =>
1414
- this._request('PUT', `/api/v1/tokens/${tokenId}/metadata`, { auditMessage,
1415
- rawBody: body, skipResponseTransform: true,
2055
+ this._request("PUT", `/api/v1/tokens/${tokenId}/metadata`, {
2056
+ auditMessage,
2057
+ rawBody: body,
2058
+ skipResponseTransform: true,
1416
2059
  }),
1417
2060
  /**
1418
2061
  * Remove all metadata from a token.
1419
2062
  * @param {string} tokenId - The token ID
1420
2063
  */
1421
2064
  deleteMetadata: (tokenId, auditMessage) =>
1422
- this._request('DELETE', `/api/v1/tokens/${tokenId}/metadata`, { auditMessage,
2065
+ this._request("DELETE", `/api/v1/tokens/${tokenId}/metadata`, {
2066
+ auditMessage,
1423
2067
  skipResponseTransform: true,
1424
2068
  }),
1425
2069
  /**
@@ -1428,8 +2072,10 @@ class PlaidClient {
1428
2072
  * @param {any} body - The metadata patch
1429
2073
  */
1430
2074
  patchMetadata: (tokenId, body, auditMessage) =>
1431
- this._request('PATCH', `/api/v1/tokens/${tokenId}/metadata`, { auditMessage,
1432
- rawBody: body, skipResponseTransform: true,
2075
+ this._request("PATCH", `/api/v1/tokens/${tokenId}/metadata`, {
2076
+ auditMessage,
2077
+ rawBody: body,
2078
+ skipResponseTransform: true,
1433
2079
  }),
1434
2080
  };
1435
2081
 
@@ -1445,14 +2091,22 @@ class PlaidClient {
1445
2091
  createSSEConnection(this, projectId, onEvent, path),
1446
2092
 
1447
2093
  /**
1448
- * Send a message to project listeners
2094
+ * Send a message to project listeners.
2095
+ *
2096
+ * `data` may be any JSON value and is sent VERBATIM (`rawBody`): a
2097
+ * message payload is opaque application data, like `metadata` and
2098
+ * `config`, so its keys must not be re-cased on the way out. Without
2099
+ * this a key such as `case-marker` would reach listeners as
2100
+ * `caseMarker` in JS and `case_marker` in Python. `listen` restores it
2101
+ * verbatim on the way in.
1449
2102
  * @param {string} projectId - The UUID of the project to send to
1450
2103
  * @param {any} data - The message data to send
1451
2104
  * @returns {Promise<any>} Response from the send operation
1452
2105
  */
1453
2106
  sendMessage: (projectId, data, auditMessage) =>
1454
- this._request('POST', `/api/v1/projects/${projectId}/message`, { auditMessage,
1455
- body: { body: data },
2107
+ this._request("POST", `/api/v1/projects/${projectId}/message`, {
2108
+ auditMessage,
2109
+ rawBody: { body: data },
1456
2110
  }),
1457
2111
 
1458
2112
  /**
@@ -1462,8 +2116,7 @@ class PlaidClient {
1462
2116
  * @param {string} projectId - The UUID of the project to query
1463
2117
  * @returns {Promise<Array>} Array of discovered service information
1464
2118
  */
1465
- discoverServices: (projectId) =>
1466
- discoverServices(this, projectId),
2119
+ discoverServices: (projectId) => discoverServices(this, projectId),
1467
2120
 
1468
2121
  /**
1469
2122
  * Forget a previously-seen (offline) service. Maintainer-only; 409 if
@@ -1477,26 +2130,70 @@ class PlaidClient {
1477
2130
 
1478
2131
  /**
1479
2132
  * Register as a service and handle incoming work requests.
2133
+ *
2134
+ * The registration reopens its channel whenever it drops, so a server
2135
+ * restart needs no service restart.
1480
2136
  * @param {string} projectId - The UUID of the project to serve
1481
2137
  * @param {Object} serviceInfo - Service information {serviceId, serviceName, description}
1482
2138
  * @param {function} onServiceRequest - Callback (data, responseHelper)
1483
2139
  * @param {Object} [extras] - Optional additional service metadata
1484
- * @returns {Object} Service registration object with .stop() method
2140
+ * @param {function} [onStatus] - Optional callback (event, projectId, detail) for
2141
+ * connection transitions: 'registered', 'reconnected', 'disconnected'
2142
+ * @returns {Object} Service registration with .stop(), .isRunning(), .isConnected()
1485
2143
  */
1486
- serve: (projectId, serviceInfo, onServiceRequest, extras) =>
1487
- serve(this, projectId, serviceInfo, onServiceRequest, extras),
2144
+ serve: (projectId, serviceInfo, onServiceRequest, extras, onStatus) =>
2145
+ serve(this, projectId, serviceInfo, onServiceRequest, extras, onStatus),
1488
2146
 
1489
2147
  /**
1490
2148
  * Request a service to perform work and await its result.
2149
+ *
2150
+ * The request outlives this call: after a timeout, an abort, or a
2151
+ * dropped connection the service goes on, and `attachServiceRequest`
2152
+ * collects the result given the request id, which `opts.onAccepted`
2153
+ * receives as soon as the server has taken the request. Pass
2154
+ * `opts.requestId` (a UUID you mint) to know the id before submitting;
2155
+ * submitting an id that names a request you already made rejoins it
2156
+ * instead of starting another.
1491
2157
  * @param {string} projectId - The UUID of the project
1492
2158
  * @param {string} serviceId - The ID of the service to request
1493
2159
  * @param {any} data - The request data
1494
- * @param {number} [timeout] - Timeout in milliseconds (default: 10000)
2160
+ * @param {number} [timeout=10000] - Timeout in ms
1495
2161
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
1496
- * @returns {Promise<any>} Service response
2162
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
2163
+ * @param {Object} [opts] - {requestId, onAccepted}
2164
+ * @returns {Promise<any>} The service's result
1497
2165
  */
1498
- requestService: (projectId, serviceId, data, timeout, onProgress) =>
1499
- requestService(this, projectId, serviceId, data, timeout, onProgress),
2166
+ requestService: (projectId, serviceId, data, timeout, onProgress, signal, opts) =>
2167
+ requestService(this, projectId, serviceId, data, timeout, onProgress, signal, opts),
2168
+
2169
+ /**
2170
+ * Rejoin a service request made earlier and await its result: the
2171
+ * latest progress is replayed, then the result comes, or at once if the
2172
+ * request already finished. Only the user who submitted it (or an
2173
+ * admin). Rejects with an error whose `status` is 404 when the request
2174
+ * is unknown or expired (a finished request's result is kept for a
2175
+ * while, not forever).
2176
+ * @param {string} projectId - The UUID of the project
2177
+ * @param {string} requestId - The request id
2178
+ * @param {number} [timeout=10000] - Timeout in ms
2179
+ * @param {function} [onProgress] - Called with each progress payload
2180
+ * @param {AbortSignal} [signal] - Abort to stop waiting
2181
+ * @returns {Promise<any>} The service's result
2182
+ */
2183
+ attachServiceRequest: (projectId, requestId, timeout, onProgress, signal) =>
2184
+ attachServiceRequest(this, projectId, requestId, timeout, onProgress, signal),
2185
+
2186
+ /**
2187
+ * Ask the service to stop a request made earlier. The request still
2188
+ * ends with whatever the service then reports, on the stream of whoever
2189
+ * is awaiting it. Rejects with 404 if unknown or expired, 409 once
2190
+ * finished.
2191
+ * @param {string} projectId - The UUID of the project
2192
+ * @param {string} requestId - The request id
2193
+ * @returns {Promise<void>}
2194
+ */
2195
+ cancelServiceRequest: (projectId, requestId) =>
2196
+ cancelServiceRequest(this, projectId, requestId),
1500
2197
  };
1501
2198
 
1502
2199
  /**
@@ -1534,8 +2231,201 @@ class PlaidClient {
1534
2231
  * For 'count': {return: 'count', count}. Entity cells are full entity objects
1535
2232
  * (same shape as the GET endpoints).
1536
2233
  */
2234
+ // bypassBatch: a query is a read. `client.batched()` flips one flag on the
2235
+ // whole client, so a query issued by unrelated code while a batch is open
2236
+ // (the IGT island fetching precedent while a document reconciles) used to
2237
+ // be queued into it: the caller got `{batched: true}` instead of results,
2238
+ // and the server ran the query against the batch's tx Connection, which
2239
+ // 500s and rolls back every write in that batch.
1537
2240
  this.query = (body, auditMessage) =>
1538
- this._request('POST', '/api/v1/query', { auditMessage, body });
2241
+ this._request("POST", "/api/v1/query", { auditMessage, body, bypassBatch: true });
2242
+
2243
+ // Logical-operation groups (audit-log grouping). There is no create: a
2244
+ // group row is made lazily by the first write carrying `?group-id=`
2245
+ // (see beginOperation). Not an audited write, so no auditMessage arg.
2246
+ this.operationGroups = {
2247
+ /**
2248
+ * Get a logical-operation group (its label + creator).
2249
+ * @param {string} id - The group id
2250
+ */
2251
+ get: (id) => this._request("GET", `/api/v1/operation-groups/${id}`),
2252
+ /**
2253
+ * Relabel a logical-operation group after the fact. Owner or admin only.
2254
+ * @param {string} id - The group id
2255
+ * @param {string|null} message - The new label
2256
+ */
2257
+ update: (id, message) =>
2258
+ this._request("PATCH", `/api/v1/operation-groups/${id}`, {
2259
+ body: { message },
2260
+ }),
2261
+ };
2262
+
2263
+ this.comments = {
2264
+ /**
2265
+ * Post a comment on an entity. Requires write access to the entity's
2266
+ * project, or to the vocabulary for a vocab-item; the author is the
2267
+ * authenticated caller. Comments are not audited and do not bump the
2268
+ * document version.
2269
+ *
2270
+ * A comment outlives its anchor: deleting the entity does not delete
2271
+ * the comment. `anchorLabel` is the caption shown once that happens
2272
+ * ("Gloss of ktab, sentence 4"), so pass what the comment is about.
2273
+ * @param {'document'|'text'|'token'|'span'|'relation'|'vocab-item'} entityType - What kind of thing is being commented on
2274
+ * @param {string} entityId - The commented entity's id
2275
+ * @param {string} body - The comment text (1..10000 characters)
2276
+ * @param {object} [opts]
2277
+ * @param {string} [opts.anchorLabel] - What the comment is about, in words (at most 200 characters)
2278
+ */
2279
+ create: (entityType, entityId, body, { anchorLabel } = {}) =>
2280
+ this._request("POST", "/api/v1/comments", {
2281
+ body: bodyOf({
2282
+ "entity-type": entityType,
2283
+ "entity-id": entityId,
2284
+ body,
2285
+ "anchor-label": anchorLabel,
2286
+ }),
2287
+ }),
2288
+ /**
2289
+ * Read one comment.
2290
+ * @param {string} id - The comment id
2291
+ */
2292
+ get: (id) => this._request("GET", `/api/v1/comments/${id}`),
2293
+ /**
2294
+ * Edit a comment's body. Only the comment's AUTHOR may do this - not
2295
+ * maintainers, not admins. Sets `edited` on the comment.
2296
+ * @param {string} id - The comment id
2297
+ * @param {string} body - The replacement text
2298
+ */
2299
+ update: (id, body) =>
2300
+ this._request("PATCH", `/api/v1/comments/${id}`, { body: { body } }),
2301
+ /**
2302
+ * Delete a comment. The author may delete their own; a project
2303
+ * maintainer (or admin) may delete any.
2304
+ * @param {string} id - The comment id
2305
+ */
2306
+ delete: (id) => this._request("DELETE", `/api/v1/comments/${id}`),
2307
+ /**
2308
+ * List comments in a project, oldest first. Transparently follows
2309
+ * pagination cursors and returns the full flat array.
2310
+ * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching - use listPage() for a single page in a batch.
2311
+ * @param {string} projectId - The project to read
2312
+ * @param {object} [filters] - Narrow the scope
2313
+ * @param {string} [filters.documentId] - Every comment anywhere in one document
2314
+ * @param {string} [filters.entityType] - With entityId, one entity's thread
2315
+ * @param {string} [filters.entityId] - With entityType, one entity's thread
2316
+ */
2317
+ list: (projectId, { documentId, entityType, entityId } = {}) =>
2318
+ listAll(this, `/api/v1/projects/${projectId}/comments`, {
2319
+ query: {
2320
+ "document-id": documentId,
2321
+ "entity-type": entityType,
2322
+ "entity-id": entityId,
2323
+ },
2324
+ }),
2325
+ /**
2326
+ * Fetch a single page of a project's comments.
2327
+ * @param {string} projectId - The project to read
2328
+ * @param {object} [opts]
2329
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2330
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
2331
+ * @param {string} [opts.documentId] - Every comment anywhere in one document
2332
+ * @param {string} [opts.entityType] - With entityId, one entity's thread
2333
+ * @param {string} [opts.entityId] - With entityType, one entity's thread
2334
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2335
+ */
2336
+ listPage: (projectId, { limit, cursor, documentId, entityType, entityId } = {}) =>
2337
+ listPage(this, `/api/v1/projects/${projectId}/comments`, {
2338
+ limit,
2339
+ cursor,
2340
+ query: {
2341
+ "document-id": documentId,
2342
+ "entity-type": entityType,
2343
+ "entity-id": entityId,
2344
+ },
2345
+ }),
2346
+ /**
2347
+ * Async-iterate a project's comments page by page; yields each page's entries array.
2348
+ * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching - use listPage() for a single page in a batch.
2349
+ * @param {string} projectId - The project to read
2350
+ * @param {object} [opts]
2351
+ * @param {number} [opts.pageSize] - Per-request page size
2352
+ * @param {string} [opts.documentId] - Every comment anywhere in one document
2353
+ * @param {string} [opts.entityType] - With entityId, one entity's thread
2354
+ * @param {string} [opts.entityId] - With entityType, one entity's thread
2355
+ * @returns {AsyncGenerator<Array>}
2356
+ */
2357
+ iterPages: (projectId, { pageSize, documentId, entityType, entityId } = {}) =>
2358
+ iterPages(this, `/api/v1/projects/${projectId}/comments`, {
2359
+ pageSize,
2360
+ query: {
2361
+ "document-id": documentId,
2362
+ "entity-type": entityType,
2363
+ "entity-id": entityId,
2364
+ },
2365
+ }),
2366
+ /**
2367
+ * Comment counts per entity, as an `{entityId: n}` map, over the same
2368
+ * scope and filters as `list`. One cheap request paints a comment
2369
+ * indicator on every annotated item in a document without paging
2370
+ * through the bodies.
2371
+ *
2372
+ * The response is NOT key-transformed: its keys are entity ids, and
2373
+ * camelCasing would mangle the hyphens in a UUID.
2374
+ * @param {string} projectId - The project to read
2375
+ * @param {object} [filters] - Same filters as list()
2376
+ * @returns {Promise<Object<string, number>>}
2377
+ */
2378
+ counts: (projectId, { documentId, entityType, entityId } = {}) =>
2379
+ this._request("GET", `/api/v1/projects/${projectId}/comments/counts`, {
2380
+ queryParams: {
2381
+ "document-id": documentId,
2382
+ "entity-type": entityType,
2383
+ "entity-id": entityId,
2384
+ },
2385
+ skipResponseTransform: true,
2386
+ }),
2387
+ /**
2388
+ * List the comments on a vocabulary's entries, oldest first. Requires
2389
+ * read access to the vocabulary. Transparently follows pagination
2390
+ * cursors and returns the full flat array.
2391
+ * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching - use listInVocabPage() for a single page in a batch.
2392
+ * @param {string} vocabId - The vocab layer to read
2393
+ * @param {object} [filters]
2394
+ * @param {string} [filters.entityId] - One entry's thread
2395
+ */
2396
+ listInVocab: (vocabId, { entityId } = {}) =>
2397
+ listAll(this, `/api/v1/vocab-layers/${vocabId}/comments`, {
2398
+ query: { "entity-id": entityId },
2399
+ }),
2400
+ /**
2401
+ * Fetch a single page of a vocabulary's comments.
2402
+ * @param {string} vocabId - The vocab layer to read
2403
+ * @param {object} [opts]
2404
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2405
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
2406
+ * @param {string} [opts.entityId] - One entry's thread
2407
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2408
+ */
2409
+ listInVocabPage: (vocabId, { limit, cursor, entityId } = {}) =>
2410
+ listPage(this, `/api/v1/vocab-layers/${vocabId}/comments`, {
2411
+ limit,
2412
+ cursor,
2413
+ query: { "entity-id": entityId },
2414
+ }),
2415
+ /**
2416
+ * Comment counts per entry of a vocabulary, as an `{entityId: n}` map.
2417
+ * The response is NOT key-transformed: its keys are entry ids.
2418
+ * @param {string} vocabId - The vocab layer to read
2419
+ * @param {object} [filters]
2420
+ * @param {string} [filters.entityId] - One entry only
2421
+ * @returns {Promise<Object<string, number>>}
2422
+ */
2423
+ countsInVocab: (vocabId, { entityId } = {}) =>
2424
+ this._request("GET", `/api/v1/vocab-layers/${vocabId}/comments/counts`, {
2425
+ queryParams: { "entity-id": entityId },
2426
+ skipResponseTransform: true,
2427
+ }),
2428
+ };
1539
2429
  }
1540
2430
 
1541
2431
  // --- Core methods ---
@@ -1559,51 +2449,96 @@ class PlaidClient {
1559
2449
  }
1560
2450
 
1561
2451
  /**
1562
- * Set a custom audit-log message applied to every subsequent write request,
1563
- * OVERRIDING the server's auto-generated description (e.g. "Patch metadata
1564
- * on span X"). The message may template the endpoint's own path/query/body
1565
- * params with `{param}` placeholders, resolved server-side — e.g.
1566
- * `"Approve span {spanId}"`. Placeholder names are case/separator-insensitive
1567
- * (`{spanId}` == `{span-id}` == `{span_id}`), so use the camelCase names this
1568
- * client uses. Applies to GET-less requests
1569
- * only, including every operation queued in a batch.
1570
- * @param {string} message - The custom message (template).
2452
+ * Begin a LOGICAL OPERATION: a user-meaningful action ("Merge morphemes",
2453
+ * "Re-transcribe") implemented as many low-level writes, possibly across
2454
+ * several batches and even a service round-trip. Until `endOperation()`,
2455
+ * every write is stamped with a client-minted `?group-id=` (and the message)
2456
+ * so the audit log shows the whole run as ONE expandable entry labeled
2457
+ * `message`, with each write's own description underneath.
2458
+ *
2459
+ * Grouping is orthogonal to batches: a batch is a transaction boundary, an
2460
+ * operation is an intent boundary. An operation is NOT atomic — if write 3
2461
+ * of 5 fails, writes 1–2 stay committed (and logged under the group). Use a
2462
+ * batch inside the operation for any step that must be all-or-nothing.
2463
+ *
2464
+ * Nesting flattens: a `beginOperation` while one is open is a no-op that
2465
+ * joins the outer operation (the outer label wins), and the matching
2466
+ * `endOperation` is likewise a no-op. The label is recorded on the FIRST
2467
+ * write, so an operation that is never ended (crash, closed tab) is still
2468
+ * labeled in the log.
2469
+ *
2470
+ * @param {string} message - Human label for the operation.
2471
+ * @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).
2472
+ * @returns {string} The operation's group id.
1571
2473
  */
1572
- setAuditMessage(message) {
1573
- this.auditMessage = message;
1574
- return this;
2474
+ beginOperation(message, { id } = {}) {
2475
+ if (this.operationGroup) {
2476
+ this.operationGroup.depth += 1;
2477
+ return this.operationGroup.id;
2478
+ }
2479
+ this.operationGroup = {
2480
+ id: id || crypto.randomUUID(),
2481
+ message: message == null ? null : String(message),
2482
+ depth: 1,
2483
+ written: false,
2484
+ refined: undefined,
2485
+ };
2486
+ return this.operationGroup.id;
1575
2487
  }
1576
2488
 
1577
- /** Clear the ambient custom audit-log message (revert to auto-generated). */
1578
- clearAuditMessage() {
1579
- this.auditMessage = null;
1580
- return this;
2489
+ /**
2490
+ * End the current logical operation. With no argument this is purely local
2491
+ * (no request). Pass a refined `message` to relabel the group now that the
2492
+ * outcome is known (e.g. `endOperation('Merged 3 morphemes')`) — that sends
2493
+ * one PATCH, skipped if the operation never wrote anything. A refine from a
2494
+ * nested (flattened) `endOperation` is ignored; the outer label wins.
2495
+ * @param {string} [message] - Optional refined label.
2496
+ * @returns {Promise<void>}
2497
+ */
2498
+ async endOperation(message) {
2499
+ const group = this.operationGroup;
2500
+ if (!group) return;
2501
+ if (group.depth > 1) {
2502
+ group.depth -= 1;
2503
+ return;
2504
+ }
2505
+ this.operationGroup = null;
2506
+ const refined = message !== undefined ? message : group.refined;
2507
+ if (refined !== undefined && group.written) {
2508
+ try {
2509
+ await this.operationGroups.update(group.id, refined);
2510
+ } catch (e) {
2511
+ // 404: the group never materialized server-side (every tagged write
2512
+ // failed or a batch was aborted). Nothing to relabel — not an error.
2513
+ if (e.status !== 404) throw e;
2514
+ }
2515
+ }
1581
2516
  }
1582
2517
 
1583
2518
  /**
1584
- * Run `fn` with a custom audit-log message in effect, restoring the previous
1585
- * message afterward (supports nesting). Use it to scope ONE call (per-call
1586
- * precision) or MANY (a logical unit):
2519
+ * Run `fn` as one logical operation (see beginOperation), ending it when
2520
+ * `fn` settles — including on throw. `fn` receives a `setMessage(msg)`
2521
+ * callback to refine the label once the outcome is known.
1587
2522
  *
1588
- * await client.withAuditMessage('Approve span {spanId}', () =>
1589
- * client.spans.update(spanId, ...));
1590
- *
1591
- * await client.withAuditMessage('Import sentence', async () => {
1592
- * await client.tokens.create(...);
1593
- * await client.spans.create(...);
2523
+ * await client.withOperation('Merge morphemes', async (setMessage) => {
2524
+ * await client.batched(() => { ... });
2525
+ * setMessage(`Merged ${n} morphemes`);
1594
2526
  * });
1595
2527
  *
1596
- * @param {string} message - The custom message (template).
1597
- * @param {() => (Promise<any>|any)} fn - The work to run with the message set.
2528
+ * @param {string} message - Human label for the operation.
2529
+ * @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
1598
2530
  * @returns {Promise<any>} Whatever `fn` resolves to.
1599
2531
  */
1600
- async withAuditMessage(message, fn) {
1601
- const prev = this.auditMessage;
1602
- this.auditMessage = message;
2532
+ async withOperation(message, fn) {
2533
+ this.beginOperation(message);
2534
+ const group = this.operationGroup;
2535
+ const setMessage = (msg) => {
2536
+ if (group.depth === 1) group.refined = msg;
2537
+ };
1603
2538
  try {
1604
- return await fn();
2539
+ return await fn(setMessage);
1605
2540
  } finally {
1606
- this.auditMessage = prev;
2541
+ await this.endOperation();
1607
2542
  }
1608
2543
  }
1609
2544
 
@@ -1623,7 +2558,7 @@ class PlaidClient {
1623
2558
  */
1624
2559
  async submitBatch() {
1625
2560
  if (!this.isBatching) {
1626
- throw new Error('No active batch. Call beginBatch() first.');
2561
+ throw new Error("No active batch. Call beginBatch() first.");
1627
2562
  }
1628
2563
 
1629
2564
  if (this.batchOperations.length === 0) {
@@ -1632,55 +2567,84 @@ class PlaidClient {
1632
2567
  }
1633
2568
 
1634
2569
  try {
1635
- let url = `${this.baseUrl}/api/v1/batch`;
1636
- const body = this.batchOperations.map(op => ({
2570
+ const url = `${this.baseUrl}/api/v1/batch`;
2571
+ const ops = this.batchOperations.map((op) => ({
1637
2572
  path: op.path,
1638
2573
  method: op.method.toUpperCase(),
1639
2574
  ...(op.body && { body: op.body }),
1640
2575
  }));
2576
+ // The server caps a batch at MAX_BATCH_OPS so one transaction cannot
2577
+ // hold the write lock without bound. A larger batch goes as consecutive
2578
+ // requests, results concatenated in queue order: it could not have been
2579
+ // one transaction anyway, and a repair or bulk edit over a big document
2580
+ // must not fail on its size alone.
2581
+ const results = [];
2582
+ for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
2583
+ results.push(...(await this._postBatch(url, ops.slice(i, i + MAX_BATCH_OPS))));
2584
+ }
2585
+ return results;
2586
+ } finally {
2587
+ this.isBatching = false;
2588
+ this.batchOperations = [];
2589
+ }
2590
+ }
1641
2591
 
1642
- const fetchOptions = {
1643
- method: 'POST',
1644
- headers: {
1645
- 'Authorization': `Bearer ${this.token}`,
1646
- 'Content-Type': 'application/json',
1647
- },
1648
- body: JSON.stringify(body),
1649
- };
1650
- const signal = timeoutSignal(this.timeout);
1651
- if (signal) fetchOptions.signal = signal;
2592
+ /** POST one batch request (at most MAX_BATCH_OPS operations); resolves to its transformed results. */
2593
+ async _postBatch(url, body) {
2594
+ const fetchOptions = {
2595
+ method: "POST",
2596
+ headers: {
2597
+ Authorization: `Bearer ${this.token}`,
2598
+ "Content-Type": "application/json",
2599
+ },
2600
+ body: JSON.stringify(body),
2601
+ };
1652
2602
 
1653
- try {
1654
- const response = await fetch(url, fetchOptions);
1655
- if (!response.ok) {
1656
- throw makeHttpError(response, await parseErrorBody(response), url, 'POST');
2603
+ try {
2604
+ // Retry a 503: the batch is atomic, so a refused one wrote nothing and
2605
+ // repeating it is safe. A fresh timeout signal per attempt — an
2606
+ // AbortSignal.timeout stays aborted once it fires.
2607
+ const response = await retryWhileBusy(async () => {
2608
+ const signal = timeoutSignal(this.batchTimeout);
2609
+ const res = await fetch(
2610
+ url,
2611
+ signal ? { ...fetchOptions, signal } : fetchOptions,
2612
+ );
2613
+ if (!res.ok) {
2614
+ throw makeHttpError(res, await parseErrorBody(res), url, "POST");
1657
2615
  }
2616
+ return res;
2617
+ });
2618
+
2619
+ const results = await response.json();
1658
2620
 
1659
- const results = await response.json();
1660
-
1661
- // Extract document versions from each batch response
1662
- for (const result of results) {
1663
- if (result.headers && result.headers['X-Document-Versions']) {
1664
- try {
1665
- const versionsMap = JSON.parse(result.headers['X-Document-Versions']);
1666
- if (typeof versionsMap === 'object' && versionsMap !== null) {
1667
- // Clone once per response, then merge — not once per entry.
1668
- this.documentVersions = { ...this.documentVersions, ...versionsMap };
1669
- }
1670
- } catch (e) {
1671
- console.warn('Failed to parse document versions header from batch response:', e);
2621
+ // Extract document versions from each batch response
2622
+ for (const result of results) {
2623
+ if (result.headers && result.headers["X-Document-Versions"]) {
2624
+ try {
2625
+ const versionsMap = JSON.parse(
2626
+ result.headers["X-Document-Versions"],
2627
+ );
2628
+ if (typeof versionsMap === "object" && versionsMap !== null) {
2629
+ // Clone once per response, then merge — not once per entry.
2630
+ this.documentVersions = {
2631
+ ...this.documentVersions,
2632
+ ...versionsMap,
2633
+ };
1672
2634
  }
2635
+ } catch (e) {
2636
+ console.warn(
2637
+ "Failed to parse document versions header from batch response:",
2638
+ e,
2639
+ );
1673
2640
  }
1674
2641
  }
1675
-
1676
- return results.map(result => transformResponse(result));
1677
- } catch (error) {
1678
- if (error.status) throw error;
1679
- throw makeNetworkError(error, url, 'POST');
1680
2642
  }
1681
- } finally {
1682
- this.isBatching = false;
1683
- this.batchOperations = [];
2643
+
2644
+ return results.map((result) => transformResponse(result));
2645
+ } catch (error) {
2646
+ if (error.status) throw error;
2647
+ throw makeNetworkError(error, url, "POST");
1684
2648
  }
1685
2649
  }
1686
2650
 
@@ -1739,29 +2703,114 @@ class PlaidClient {
1739
2703
  * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
1740
2704
  * @returns {Promise<PlaidClient>} - Authenticated client instance
1741
2705
  */
2706
+ /**
2707
+ * Build the link to hand someone for an invite code. The server never sees
2708
+ * an app URL, so the app that minted the invite is the one that names it.
2709
+ * Both SPAs use HashRouter, so the code rides in the fragment — which also
2710
+ * keeps it out of server access logs.
2711
+ * @param {string} appUrl - Where the SPA lives, e.g. "https://plaid.example.org/igt/"
2712
+ * @param {string} code - The code returned by invites.create()
2713
+ * @returns {string}
2714
+ */
2715
+ static inviteUrl(appUrl, code) {
2716
+ return `${appUrl.replace(/#.*$/, "").replace(/\/$/, "")}/#/invite/${encodeURIComponent(code)}`;
2717
+ }
2718
+
2719
+ /**
2720
+ * Describe an invite code, with NO authentication — this is what a signup
2721
+ * page calls before the redeemer has an account. Returns the kind of link
2722
+ * ("signup" or "password-reset"), its status ("active", "used", "expired",
2723
+ * "revoked"), and the project it grants access to, if any.
2724
+ *
2725
+ * Throws a 404-shaped error if the code is unknown. A known-but-dead code
2726
+ * resolves normally with a non-"active" status, so the page can say why.
2727
+ * @param {string} baseUrl - The API base URL
2728
+ * @param {string} code - The invite code
2729
+ * @param {object} [options]
2730
+ * @returns {Promise<{kind: string, status: string, expiresAt: string, projectName?: string, projectRole?: string, email?: string, grantAdmin: boolean}>}
2731
+ */
2732
+ static async lookupInvite(baseUrl, code, options = {}) {
2733
+ return anonymousPost(
2734
+ baseUrl,
2735
+ "/api/v1/invite-codes/lookup",
2736
+ { code },
2737
+ options,
2738
+ );
2739
+ }
2740
+
2741
+ /**
2742
+ * Redeem an invite code, with NO authentication.
2743
+ *
2744
+ * For a signup invite, pass `email` and `password` to create the account
2745
+ * (and optionally `displayName`); the invite's grants are applied in the
2746
+ * same transaction. For a password reset link, pass `password` only.
2747
+ * Resolves to a logged-in client, exactly like login() — the redeemer just
2748
+ * chose these credentials, so there is no reason to send them to a login
2749
+ * form to retype them.
2750
+ * @param {string} baseUrl - The API base URL
2751
+ * @param {string} code - The invite code
2752
+ * @param {object} credentials
2753
+ * @param {string} [credentials.email] - The new account's email address,
2754
+ * which becomes its id and login (signup only)
2755
+ * @param {string} [credentials.displayName] - How the new user is shown in
2756
+ * the UI; defaults to the local part of the email (signup only)
2757
+ * @param {string} credentials.password - Desired password (min 8 characters)
2758
+ * @param {object} [options]
2759
+ * @returns {Promise<{client: PlaidClient, userId: string, kind: string}>}
2760
+ */
2761
+ static async redeemInvite(
2762
+ baseUrl,
2763
+ code,
2764
+ { email, password, displayName } = {},
2765
+ options = {},
2766
+ ) {
2767
+ const data = await anonymousPost(
2768
+ baseUrl,
2769
+ "/api/v1/invite-codes/redeem",
2770
+ bodyOf({ code, email, password, "display-name": displayName }),
2771
+ options,
2772
+ );
2773
+ return {
2774
+ client: new PlaidClient(
2775
+ baseUrl.replace(/\/$/, ""),
2776
+ data.token || "",
2777
+ options,
2778
+ ),
2779
+ userId: data.userId,
2780
+ kind: data.kind,
2781
+ };
2782
+ }
2783
+
1742
2784
  static async login(baseUrl, userId, password, options = {}) {
1743
- baseUrl = baseUrl.replace(/\/$/, '');
2785
+ baseUrl = baseUrl.replace(/\/$/, "");
1744
2786
  const url = `${baseUrl}/api/v1/login`;
1745
2787
  try {
1746
2788
  const fetchOptions = {
1747
- method: 'POST',
1748
- headers: { 'Content-Type': 'application/json' },
1749
- body: JSON.stringify({ 'user-id': userId, password }),
2789
+ method: "POST",
2790
+ headers: { "Content-Type": "application/json" },
2791
+ body: JSON.stringify({ "user-id": userId, password }),
1750
2792
  };
1751
- const signal = timeoutSignal(options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS);
2793
+ const signal = timeoutSignal(
2794
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS,
2795
+ );
1752
2796
  if (signal) fetchOptions.signal = signal;
1753
2797
 
1754
2798
  const response = await fetch(url, fetchOptions);
1755
2799
  if (!response.ok) {
1756
- throw makeHttpError(response, await parseErrorBody(response), url, 'POST');
2800
+ throw makeHttpError(
2801
+ response,
2802
+ await parseErrorBody(response),
2803
+ url,
2804
+ "POST",
2805
+ );
1757
2806
  }
1758
2807
 
1759
2808
  const data = await response.json();
1760
- const token = data.token || '';
2809
+ const token = data.token || "";
1761
2810
  return new PlaidClient(baseUrl, token, options);
1762
2811
  } catch (error) {
1763
2812
  if (error.status) throw error;
1764
- throw makeNetworkError(error, url, 'POST');
2813
+ throw makeNetworkError(error, url, "POST");
1765
2814
  }
1766
2815
  }
1767
2816
  }
@@ -1771,8 +2820,21 @@ export { PlaidClient };
1771
2820
 
1772
2821
  // Unicode code-point helpers for text offsets (token begin/end are code-point
1773
2822
  // indices). See ./codepoint.js.
1774
- export { cpLength, cpSlice, cpSlicer, utf16ToCp, cpToUtf16, cpIndexOf } from './codepoint.js';
1775
- export { PLAID_NAMESPACE, ROLE_KEY, ROLES, readRole, findByRole } from './roles.js';
2823
+ export {
2824
+ cpLength,
2825
+ cpSlice,
2826
+ cpSlicer,
2827
+ utf16ToCp,
2828
+ cpToUtf16,
2829
+ cpIndexOf,
2830
+ } from "./codepoint.js";
2831
+ export {
2832
+ PLAID_NAMESPACE,
2833
+ ROLE_KEY,
2834
+ ROLES,
2835
+ readRole,
2836
+ findByRole,
2837
+ } from "./roles.js";
1776
2838
  // Service self-description helpers: filter discovered services by task, read a
1777
2839
  // service's parameter schema/summary, and build/coerce form values. See
1778
2840
  // ./serviceSchema.js.
@@ -1784,18 +2846,34 @@ export {
1784
2846
  getServiceSummary,
1785
2847
  buildDefaultValues,
1786
2848
  coerceParamValues,
1787
- } from './serviceSchema.js';
1788
- // Provenance: the cross-app convention for machine-provided vs human-labeled
1789
- // information (flat prov/provSource/provConfirmed metadata; absence = human),
1790
- // plus the machine-writer contract. See ./provenance.js and the manual,
1791
- // "Provenance".
2849
+ } from "./serviceSchema.js";
2850
+ // Provenance: the cross-app convention for who made an annotation (flat
2851
+ // prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
2852
+ // a machine; 'contributed' = a person whose work is reviewed), plus the
2853
+ // machine-writer contract. See ./provenance.js and the manual, "Provenance".
1792
2854
  export {
1793
2855
  PROV,
1794
2856
  PROV_STATES,
2857
+ PROV_CONFIRMED,
1795
2858
  stampInferred,
1796
2859
  confirmedInferred,
2860
+ stampContributed,
1797
2861
  provState,
2862
+ provOrigin,
2863
+ isMachine,
1798
2864
  isProtected,
2865
+ needsReview,
1799
2866
  verifyOnEdit,
2867
+ contributeOnEdit,
2868
+ mergeMetadata,
1800
2869
  serviceSource,
1801
- } from './provenance.js';
2870
+ userSource,
2871
+ REVIEW_KEY,
2872
+ PROJECT_ROLES,
2873
+ readReview,
2874
+ projectRole,
2875
+ isReviewed,
2876
+ withReviewedUser,
2877
+ writerPolicy,
2878
+ } from "./provenance.js";
2879
+ export { MAX_BATCH_OPS };