@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.21

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,26 +2,48 @@
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
+ queueRequest,
9
+ restampDocumentVersion,
10
+ extractDocumentVersions,
11
+ parseErrorBody,
12
+ makeHttpError,
13
+ makeNetworkError,
14
+ timeoutSignal,
15
+ retryWhileBusy,
16
+ retryUnknown,
17
+ nextIdempotencyKey,
18
+ mergeVersions,
19
+ IDEMPOTENCY_HEADER,
20
+ REPLAYED_HEADER,
21
+ DEFAULT_TIMEOUT_MS,
22
+ DEFAULT_BATCH_TIMEOUT_MS,
23
+ } from "./http.js";
24
+ import { uuidv7 } from "./ids.js";
25
+ import { constraintsBody } from "./constraints.js";
26
+ import { listAll, listPage, iterPages } from "./pagination.js";
27
+ import { recorderFor } from "./events.js";
28
+ import { withDocumentLock } from "./documentLock.js";
29
+ import { createSSEConnection } from "./sse.js";
30
+ import { makeBatchRef, rebaseRefs } from "./batchRef.js";
31
+ import { markReplayed, wasReplayed } from "./replayed.js";
12
32
  import {
13
33
  discoverServices,
14
34
  discardService,
15
35
  serve,
16
36
  requestService,
17
- } from './services.js';
37
+ attachServiceRequest,
38
+ cancelServiceRequest,
39
+ } from "./services.js";
18
40
 
19
41
  // Helper: normalize a document-read `layers` filter to the wire's
20
42
  // comma-separated form. Accepts an array of layer ids or a ready-made string;
21
43
  // anything empty becomes undefined so no `?layers=` is sent at all.
22
44
  function layersParam(layers) {
23
45
  if (layers === undefined || layers === null) return undefined;
24
- const joined = Array.isArray(layers) ? layers.join(',') : String(layers);
46
+ const joined = Array.isArray(layers) ? layers.join(",") : String(layers);
25
47
  return joined.length > 0 ? joined : undefined;
26
48
  }
27
49
 
@@ -30,10 +52,14 @@ function layersParam(layers) {
30
52
  // becomes undefined so no `?op-types=` is sent at all.
31
53
  function opTypesParam(opTypes) {
32
54
  if (opTypes === undefined || opTypes === null) return undefined;
33
- const joined = Array.isArray(opTypes) ? opTypes.join(',') : String(opTypes);
55
+ const joined = Array.isArray(opTypes) ? opTypes.join(",") : String(opTypes);
34
56
  return joined.length > 0 ? joined : undefined;
35
57
  }
36
58
 
59
+ // The same for an audit `kinds` filter (`?kinds=`): operation kinds, such as
60
+ // "review", in a list or a ready-made string.
61
+ const kindsParam = opTypesParam;
62
+
37
63
  // Helper: build body object, filtering out undefined values
38
64
  function bodyOf(obj) {
39
65
  const result = {};
@@ -47,28 +73,228 @@ function bodyOf(obj) {
47
73
  // and redemption). Deliberately does not go through PlaidClient._request: these
48
74
  // are called before any client exists, which is the whole point.
49
75
  async function anonymousPost(baseUrl, path, body, options = {}) {
50
- const base = baseUrl.replace(/\/$/, '');
76
+ const base = baseUrl.replace(/\/$/, "");
51
77
  const url = `${base}${path}`;
52
78
  try {
53
79
  const fetchOptions = {
54
- method: 'POST',
55
- headers: { 'Content-Type': 'application/json' },
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
56
82
  body: JSON.stringify(body),
57
83
  };
58
- const signal = timeoutSignal(options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS);
84
+ const signal = timeoutSignal(
85
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS,
86
+ );
87
+ if (signal) fetchOptions.signal = signal;
88
+
89
+ const response = await fetch(url, fetchOptions);
90
+ if (!response.ok) {
91
+ throw makeHttpError(
92
+ response,
93
+ await parseErrorBody(response),
94
+ url,
95
+ "POST",
96
+ );
97
+ }
98
+ return transformResponse(await response.json());
99
+ } catch (error) {
100
+ if (error.status) throw error;
101
+ throw makeNetworkError(error, url, "POST");
102
+ }
103
+ }
104
+
105
+ /**
106
+ * GET a path with no authentication, for the two endpoints a client may need
107
+ * before it has a session: /health and /api/v1/info.
108
+ */
109
+ async function anonymousGet(baseUrl, path, options = {}) {
110
+ const base = baseUrl.replace(/\/$/, "");
111
+ const url = `${base}${path}`;
112
+ try {
113
+ const fetchOptions = { method: "GET" };
114
+ const signal = timeoutSignal(
115
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS,
116
+ );
59
117
  if (signal) fetchOptions.signal = signal;
60
118
 
61
119
  const response = await fetch(url, fetchOptions);
62
120
  if (!response.ok) {
63
- throw makeHttpError(response, await parseErrorBody(response), url, 'POST');
121
+ throw makeHttpError(response, await parseErrorBody(response), url, "GET");
64
122
  }
65
123
  return transformResponse(await response.json());
66
124
  } catch (error) {
67
125
  if (error.status) throw error;
68
- throw makeNetworkError(error, url, 'POST');
126
+ throw makeNetworkError(error, url, "GET");
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The server's cap on operations per batch request (plaid.rest-api.v1.batch).
132
+ * A batch queued past it is submitted as consecutive requests.
133
+ */
134
+ const MAX_BATCH_OPS = 1000;
135
+
136
+ /**
137
+ * How long `admin.backup()` waits for the server to finish writing a backup:
138
+ * minutes on a large database, past the usual per-request timeout.
139
+ */
140
+ export const BACKUP_TIMEOUT_MS = 30 * 60 * 1000;
141
+
142
+ /**
143
+ * A batch is the client with a different `_request` (see `queueRequest`) and
144
+ * its own copy of the bundles. Everything else resolves to the client through
145
+ * the prototype: the token and base URL, strict mode and the document versions
146
+ * it tracks, the open logical operation.
147
+ */
148
+ function openBatch(client) {
149
+ const batch = Object.create(client);
150
+ batch.client = client;
151
+ batch.operations = [];
152
+ // The document each queued op's strict-mode stamp is for (null for none),
153
+ // and the logical operation it joined (null for none), index for index with
154
+ // `operations`.
155
+ batch.stampedDocuments = [];
156
+ batch.stampedGroups = [];
157
+ batch.open = true;
158
+ batch._request = (method, path, options = {}) =>
159
+ queueRequest(batch, method, path, options);
160
+ batch.batch = () => {
161
+ throw new Error("A batch is not nestable: queue on the batch you have");
162
+ };
163
+ batch.batched = batch.batch;
164
+ // A stand-in for the id op n will create, for a later op's body (see
165
+ // batchRef.js). The server fills it in inside the batch's transaction.
166
+ batch.ref = (opIndex = -1, index) => makeBatchRef(batch, opIndex, index);
167
+ batch.submit = () => submitBatch(batch);
168
+ batch.abort = () => {
169
+ batch.operations = [];
170
+ batch.stampedDocuments = [];
171
+ batch.stampedGroups = [];
172
+ batch.open = false;
173
+ };
174
+ batch._installResources();
175
+ return batch;
176
+ }
177
+
178
+ async function submitBatch(batch) {
179
+ if (!batch.open) {
180
+ throw new Error("This batch was already submitted or aborted");
181
+ }
182
+ batch.open = false;
183
+ const ops = batch.operations;
184
+ const stamps = batch.stampedDocuments;
185
+ const groups = batch.stampedGroups;
186
+ batch.operations = [];
187
+ batch.stampedDocuments = [];
188
+ batch.stampedGroups = [];
189
+ if (ops.length === 0) return [];
190
+ const client = batch.client;
191
+ const url = `${client.baseUrl}/api/v1/batch`;
192
+ // The server caps a batch at MAX_BATCH_OPS so one transaction cannot hold
193
+ // the write lock without bound. A larger batch goes as consecutive
194
+ // requests, results concatenated in queue order: it could not have been one
195
+ // transaction anyway, and a repair or bulk edit over a big document must
196
+ // not fail on its size alone. Past the first request, a strict-mode stamp
197
+ // claims the version the requests before it left (learned from their
198
+ // results), not the one the document had when the op was queued, which the
199
+ // first request has already moved on.
200
+ //
201
+ // Each request is atomic, the whole is not: a failure leaves the requests
202
+ // before it saved. The error says so, as `committed` (how many operations
203
+ // were saved) and `committedResults` (their results, in queue order), so a
204
+ // caller can tell a partial write from a batch that saved nothing. The
205
+ // failed request itself counts as unsaved, even when its answer was lost.
206
+ //
207
+ // Each request's results are marked replayed when that request was (see
208
+ // replayed.js), and the combined results when any request was: then some of
209
+ // the batch stored nothing new, which is what a caller reading the mark
210
+ // needs to know. The same holds for `committedResults`.
211
+ const results = [];
212
+ try {
213
+ await sendChunks(client, url, ops, stamps, results, groups);
214
+ } catch (error) {
215
+ if (error && typeof error === "object") {
216
+ error.committed = results.length;
217
+ error.committedResults = results;
218
+ }
219
+ throw error;
220
+ }
221
+ return results;
222
+ }
223
+
224
+ /** The document-version an op's path claims, as a number, or null. */
225
+ function claimedVersion(path) {
226
+ const v = new URLSearchParams(path.split("?")[1] ?? "").get("document-version");
227
+ return v == null ? null : Number(v);
228
+ }
229
+
230
+ async function sendChunks(client, url, ops, stamps, results, groups = []) {
231
+ // Every chunk's refs are checked before the first request goes, so a batch
232
+ // whose refs cannot resolve writes nothing.
233
+ const chunks = [];
234
+ for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
235
+ chunks.push(
236
+ ops
237
+ .slice(i, i + MAX_BATCH_OPS)
238
+ .map((op) =>
239
+ i > 0 && op.refs ? { ...op, refs: rebaseRefs(op.refs, i) } : op,
240
+ ),
241
+ );
242
+ }
243
+ for (let c = 0; c < chunks.length; c++) {
244
+ const i = c * MAX_BATCH_OPS;
245
+ let chunk = chunks[c];
246
+ // One Idempotency-Key per request, minted as the request is formed, so a
247
+ // resend of this chunk is answered from its first send when that landed.
248
+ // Inside an operation opened with keys, a chunk whose ops joined it takes
249
+ // the operation's next key and claims the version its first run claimed.
250
+ const joins = groups
251
+ .slice(i, i + chunk.length)
252
+ .some((g) => g && g === client.operationGroup);
253
+ const { key, pin, record } = nextIdempotencyKey(client, joins);
254
+ const claim = (docId) =>
255
+ pin !== undefined ? pin : client.documentVersions[docId];
256
+ if (i > 0 || pin !== undefined) {
257
+ chunk = chunk.map((op, j) => {
258
+ const docId = stamps[i + j];
259
+ if (!docId) return op;
260
+ const path = restampDocumentVersion(op.path, claim(docId));
261
+ return { ...op, path };
262
+ });
263
+ }
264
+ // Pin what the chunk actually claims: the first stamped op's version as
265
+ // its path now carries it.
266
+ const j = stamps.slice(i, i + chunk.length).findIndex(Boolean);
267
+ record(j < 0 ? null : claimedVersion(chunk[j].path));
268
+ const answered = await client._postBatch(url, chunk, { key });
269
+ results.push(...answered);
270
+ if (wasReplayed(answered)) markReplayed(results);
271
+ // The server took the chunk, so each operation it joined exists.
272
+ for (let j = i; j < i + chunk.length; j++) {
273
+ if (groups[j]) groups[j].written = true;
274
+ }
69
275
  }
70
276
  }
71
277
 
278
+ /**
279
+ * End the key frame `frame` of `group`, wherever it is among the frames open:
280
+ * the frame under it, or the one open over it, numbers from then on. A plain
281
+ * function, so a caller that borrows withOperation onto another object
282
+ * (test doubles do) keeps it.
283
+ */
284
+ function dropFrame(group, frame) {
285
+ if (!group || !frame || group.frames[0] === frame) return;
286
+ group.frames = group.frames.filter((f) => f !== frame);
287
+ }
288
+
289
+ /** The query of an events read: `types` as the wire's comma-separated list. */
290
+ function eventQuery(types, startTime, endTime) {
291
+ return {
292
+ types: Array.isArray(types) ? types.join(",") || undefined : types,
293
+ "start-time": startTime,
294
+ "end-time": endTime,
295
+ };
296
+ }
297
+
72
298
  class PlaidClient {
73
299
  /**
74
300
  * Create a new PlaidClient instance
@@ -76,28 +302,96 @@ class PlaidClient {
76
302
  * @param {string} token - The authentication token
77
303
  * @param {object} [options] - Client options
78
304
  * @param {number} [options.timeout=30000] - Per-request timeout in ms (0 or null disables it)
305
+ * @param {number} [options.batchTimeout=180000] - Timeout for batch submissions in ms (0 or null disables it)
79
306
  */
80
307
  constructor(baseUrl, token, options = {}) {
81
- this.baseUrl = baseUrl.replace(/\/$/, '');
308
+ this.baseUrl = baseUrl.replace(/\/$/, "");
82
309
  this.token = token;
83
- this.timeout = options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS;
84
- this.isBatching = false;
85
- this.batchOperations = [];
310
+ this.timeout =
311
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS;
312
+ // Batch submissions get a longer budget than a single request. Aborting a
313
+ // batch does NOT stop the server: it keeps running the transaction and
314
+ // holding the single SQLite write lock, so a client that gives up early
315
+ // just adds a retry on top of a write that is still in progress. Falls
316
+ // back to an explicit `timeout` when one was given.
317
+ this.batchTimeout =
318
+ options.batchTimeout !== undefined
319
+ ? options.batchTimeout
320
+ : options.timeout !== undefined
321
+ ? options.timeout
322
+ : DEFAULT_BATCH_TIMEOUT_MS;
323
+ // Delays (ms) before sending a keyed write again when its answer was lost
324
+ // (no response, 502, 504). See the Idempotency-Key note in http.js.
325
+ this.retryDelaysMs = options.retryDelaysMs;
86
326
  this.documentVersions = {};
327
+ // The server's clock minus this machine's, in ms, from the last response
328
+ // with a Date header (null before one). See serverNow().
329
+ this.serverClockOffsetMs = null;
87
330
  this.strictModeDocumentId = null;
331
+ // Set to a DocumentLockLost while a `documents.locked()` block's keep-alive
332
+ // has failed. Every write throws it until the block exits; see
333
+ // documentLock.js.
334
+ this.documentLockLost = null;
88
335
  // The open logical operation (audit-log group), or null. While set, every
89
336
  // write is stamped with `?group-id=` (+ `group-message`) so the audit log
90
337
  // folds them into ONE expandable entry. See beginOperation / withOperation.
91
- // Shape: { id, message, depth, written, refined }.
338
+ // Shape: { id, message, depth, written, refined, frames }, where each of
339
+ // `frames` is { keys, count, minted, depth, owned } and the last numbers.
92
340
  this.operationGroup = null;
93
341
  // Optional callback fired once (per client) when any request returns HTTP
94
342
  // 401 — i.e. the token is missing/expired/invalid. Apps use it to discard
95
343
  // the stored token and route back to login. See makeRequest in http.js.
96
344
  this.onAuthError = options.onAuthError || null;
97
345
  this._authErrorFired = false;
346
+ // Who hears that a request is being sent again (onRetry).
347
+ this._retryListeners = new Set();
348
+
349
+ this._installResources();
350
+ }
351
+
352
+ /**
353
+ * Hear every time a request is sent again: after a 503 (the database was
354
+ * busy), or for a keyed write after its answer was lost (no response, 502,
355
+ * 504). The listener gets `{ attempt, retries, delay, error }` before the
356
+ * wait. A screen that writes through the client directly can say it is
357
+ * retrying while the server does not answer, though the browser is online.
358
+ * @param {(info: {attempt: number, retries: number, delay: number, error: Error}) => void} listener
359
+ * @returns {() => void} the unsubscribe
360
+ */
361
+ onRetry(listener) {
362
+ this._retryListeners.add(listener);
363
+ return () => this._retryListeners.delete(listener);
364
+ }
98
365
 
99
- // --- API Bundles ---
366
+ /** Tell every `onRetry` listener. A listener that throws is passed over. */
367
+ _noteRetry(info) {
368
+ for (const listener of this._retryListeners) {
369
+ try {
370
+ listener(info);
371
+ } catch (_) {
372
+ /* a listener must not stop the retry */
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The server's time now, as its last response's Date header put it (to the
379
+ * second), else this machine's. Judge a time the server stamped, such as an
380
+ * audit entry's `ts`, against this rather than `Date.now()`: a browser
381
+ * clock can be minutes off.
382
+ * @returns {Date}
383
+ */
384
+ serverNow() {
385
+ return new Date(Date.now() + (this.serverClockOffsetMs ?? 0));
386
+ }
100
387
 
388
+ /**
389
+ * The API bundles (`client.documents`, `client.tokens`, ...). Installed on
390
+ * the client, and again on every batch it opens (see `batch()`): a batch is
391
+ * a view of the client with its own `_request`, so the same bundles built
392
+ * on it queue instead of sending.
393
+ */
394
+ _installResources() {
101
395
  this.vocabLinks = {
102
396
  /**
103
397
  * Create a new vocab link between tokens and a vocab item.
@@ -105,9 +399,10 @@ class PlaidClient {
105
399
  * @param {Array} tokens - The tokens to link
106
400
  * @param {any} [metadata] - Metadata for the link. Omit to leave unset; pass null to send JSON null.
107
401
  */
108
- create: (vocabItem, tokens, metadata, auditMessage) =>
109
- this._request('POST', '/api/v1/vocab-links', { auditMessage,
110
- body: bodyOf({ 'vocab-item': vocabItem, tokens, metadata }),
402
+ create: (vocabItem, tokens, metadata, auditMessage, { id } = {}) =>
403
+ this._request("POST", "/api/v1/vocab-links", {
404
+ auditMessage,
405
+ body: bodyOf({ id, "vocab-item": vocabItem, tokens, metadata }),
111
406
  }),
112
407
  /**
113
408
  * Create multiple vocab links in a single operation. Entries may
@@ -117,80 +412,188 @@ class PlaidClient {
117
412
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
118
413
  */
119
414
  bulkCreate: (body, auditMessage) =>
120
- this._request('POST', '/api/v1/vocab-links/bulk', { auditMessage, body }),
415
+ this._request("POST", "/api/v1/vocab-links/bulk", {
416
+ auditMessage,
417
+ body,
418
+ }),
121
419
  /**
122
420
  * Delete multiple vocab links in a single operation. Provide an array of IDs.
123
421
  * @param {string[]} body - The vocab link IDs to delete
124
422
  */
125
423
  bulkDelete: (body, auditMessage) =>
126
- this._request('DELETE', '/api/v1/vocab-links/bulk', { auditMessage, body }),
424
+ this._request("DELETE", "/api/v1/vocab-links/bulk", {
425
+ auditMessage,
426
+ body,
427
+ }),
127
428
  /**
128
429
  * Replace all metadata for a vocab link. The entire metadata map is replaced - existing metadata keys not included in the request will be removed.
129
430
  * @param {string} id - The resource ID
130
431
  * @param {any} body - The request body
131
432
  */
132
433
  setMetadata: (id, body, auditMessage) =>
133
- this._request('PUT', `/api/v1/vocab-links/${id}/metadata`, { auditMessage,
134
- rawBody: body, skipResponseTransform: true,
434
+ this._request("PUT", `/api/v1/vocab-links/${id}/metadata`, {
435
+ auditMessage,
436
+ rawBody: body,
135
437
  }),
136
438
  /**
137
439
  * Remove all metadata from a vocab link.
138
440
  * @param {string} id - The resource ID
139
441
  */
140
442
  deleteMetadata: (id, auditMessage) =>
141
- this._request('DELETE', `/api/v1/vocab-links/${id}/metadata`, { auditMessage,
142
- skipResponseTransform: true,
443
+ this._request("DELETE", `/api/v1/vocab-links/${id}/metadata`, {
444
+ auditMessage,
143
445
  }),
144
446
  /**
145
- * Patch (shallow-merge) metadata for a vocab link. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
447
+ * Edit metadata for a vocab link with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
146
448
  * @param {string} id - The resource ID
147
- * @param {any} body - The metadata patch
449
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
148
450
  */
149
451
  patchMetadata: (id, body, auditMessage) =>
150
- this._request('PATCH', `/api/v1/vocab-links/${id}/metadata`, { auditMessage,
151
- rawBody: body, skipResponseTransform: true,
452
+ this._request("PATCH", `/api/v1/vocab-links/${id}/metadata`, {
453
+ auditMessage,
454
+ rawBody: body,
152
455
  }),
153
456
  /**
154
457
  * Get a vocab link by ID
155
458
  * @param {string} id - The resource ID
156
- * @param {string} [asOf] - Temporal query timestamp
157
459
  */
158
- get: (id, asOf) =>
159
- this._request('GET', `/api/v1/vocab-links/${id}`, {
160
- queryParams: { 'as-of': asOf },
161
- }),
460
+ get: (id) =>
461
+ this._request("GET", `/api/v1/vocab-links/${id}`),
162
462
  /**
163
463
  * Delete a vocab link
164
464
  * @param {string} id - The resource ID
165
465
  */
166
466
  delete: (id, auditMessage) =>
167
- this._request('DELETE', `/api/v1/vocab-links/${id}`, { auditMessage }),
467
+ this._request("DELETE", `/api/v1/vocab-links/${id}`, { auditMessage }),
168
468
  };
169
469
 
170
470
  this.vocabLayers = {
171
471
  /**
172
- * Get a vocab layer by ID
472
+ * Get a vocab layer by ID. With `asOf`, the vocabulary as it was at that
473
+ * instant, read from its history: the same shape as the live read, its
474
+ * entries with `includeItems`. A vocabulary that did not exist then is a
475
+ * 404, a malformed or pruned time a 400.
173
476
  * @param {string} id - The resource ID
174
477
  * @param {boolean} [includeItems] - Include vocab items
175
- * @param {string} [asOf] - Temporal query timestamp
478
+ * @param {string} [asOf] - The moment to read at (ISO-8601 instant)
176
479
  */
177
480
  get: (id, includeItems, asOf) =>
178
- this._request('GET', `/api/v1/vocab-layers/${id}`, {
179
- queryParams: { 'include-items': includeItems, 'as-of': asOf },
481
+ this._request("GET", `/api/v1/vocab-layers/${id}`, {
482
+ queryParams: { "include-items": includeItems, "as-of": asOf },
483
+ }),
484
+ /**
485
+ * One entry of the vocabulary as it was at `asOf`, also when it has
486
+ * been deleted since. The same shape as `vocabItems.get`. A 404 when
487
+ * the entry was not in this vocabulary at that time.
488
+ * @param {string} id - The vocabulary ID
489
+ * @param {string} itemId - The entry ID
490
+ * @param {string} asOf - The moment to read at (ISO-8601 instant)
491
+ */
492
+ getItemAt: (id, itemId, asOf) =>
493
+ this._request("GET", `/api/v1/vocab-layers/${id}/items/${itemId}`, {
494
+ queryParams: { "as-of": asOf },
495
+ }),
496
+ /**
497
+ * Get the audit log of a vocabulary: every change to it or to its
498
+ * entries, folded into entries as the document log is. Links are not
499
+ * listed, they belong to the document they annotate. Transparently
500
+ * follows pagination cursors and returns the full flat array.
501
+ * @param {string} id - The vocabulary ID
502
+ * @param {string} [startTime] - Start of time range
503
+ * @param {string} [endTime] - End of time range
504
+ * @param {string[]|string} [opTypes] - Only return operations of these
505
+ * types, spelled as in an entry's `op/type` (e.g.
506
+ * `['vocab-item/delete', 'vocab-item/restore']`)
507
+ * @param {string} [itemId] - Only the changes that wrote this one entry,
508
+ * each with only its operations that did
509
+ * @param {string[]|string} [kinds] - Only the entries of operations of
510
+ * these kinds (e.g. `['review', 'guess-adoption']`), each whole
511
+ */
512
+ audit: (id, startTime, endTime, opTypes, itemId, kinds) =>
513
+ listAll(this, `/api/v1/vocab-layers/${id}/audit`, {
514
+ query: {
515
+ "start-time": startTime,
516
+ "end-time": endTime,
517
+ "op-types": opTypesParam(opTypes),
518
+ "item-id": itemId,
519
+ kinds: kindsParam(kinds),
520
+ },
521
+ }),
522
+ /**
523
+ * One page of the same log, newest-first with `order: "desc"`. Use this
524
+ * rather than audit() wherever the caller wants the recent end of a log
525
+ * that may be long: audit() walks every page before it resolves.
526
+ * @param {string} id - The vocabulary ID
527
+ * @param {object} [opts]
528
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
529
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
530
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
531
+ * @param {string} [opts.itemId] - Only the changes that wrote this one
532
+ * entry, each with only its operations that did
533
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
534
+ */
535
+ auditPage: (
536
+ id,
537
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, itemId } = {},
538
+ ) =>
539
+ listPage(this, `/api/v1/vocab-layers/${id}/audit`, {
540
+ limit,
541
+ cursor,
542
+ query: {
543
+ "start-time": startTime,
544
+ "end-time": endTime,
545
+ "op-types": opTypesParam(opTypes),
546
+ kinds: kindsParam(kinds),
547
+ order,
548
+ "item-id": itemId,
549
+ },
180
550
  }),
551
+ /**
552
+ * Put one entry of the vocabulary back as it was at `asOf`, as one
553
+ * operation. A deleted entry comes back under its original id with its
554
+ * form and fields, and a living one has its form and fields set back.
555
+ * Links are not part of an entry: a deleted entry's links come back
556
+ * through each document's own `documents.restore`. Resolves to
557
+ * `{inserted, form, metadata, total}`, `total` 0 when nothing changes.
558
+ * A form set back bumps every linking document, and the new versions
559
+ * come back in X-Document-Versions (or, past fifty documents,
560
+ * X-Document-Versions-Omitted), which the client takes up as on any
561
+ * write. A time when the entry did not exist is a 400. Maintainers of
562
+ * the vocabulary only.
563
+ * @param {string} id - The vocabulary ID
564
+ * @param {string} itemId - The entry ID
565
+ * @param {string} asOf - The moment to go back to (ISO-8601 instant),
566
+ * typically a history entry's `endTime`
567
+ * @param {object} [options] - `{ dryRun }`: with `dryRun` true nothing
568
+ * is written and the summary says what would change
569
+ * @param {string} [auditMessage] - Custom audit message for this operation
570
+ */
571
+ restoreItem: (id, itemId, asOf, { dryRun } = {}, auditMessage) =>
572
+ this._request(
573
+ "POST",
574
+ `/api/v1/vocab-layers/${id}/items/${itemId}/restore`,
575
+ {
576
+ queryParams: {
577
+ "as-of": asOf,
578
+ "dry-run": dryRun ? "true" : undefined,
579
+ },
580
+ auditMessage,
581
+ },
582
+ ),
181
583
  /**
182
584
  * Delete a vocab layer.
183
585
  * @param {string} id - The resource ID
184
586
  */
185
587
  delete: (id, auditMessage) =>
186
- this._request('DELETE', `/api/v1/vocab-layers/${id}`, { auditMessage }),
588
+ this._request("DELETE", `/api/v1/vocab-layers/${id}`, { auditMessage }),
187
589
  /**
188
590
  * Update a vocab layer's name.
189
591
  * @param {string} id - The resource ID
190
592
  * @param {string} name - The name
191
593
  */
192
594
  update: (id, name, auditMessage) =>
193
- this._request('PATCH', `/api/v1/vocab-layers/${id}`, { auditMessage,
595
+ this._request("PATCH", `/api/v1/vocab-layers/${id}`, {
596
+ auditMessage,
194
597
  body: bodyOf({ name }),
195
598
  }),
196
599
  /**
@@ -199,56 +602,65 @@ class PlaidClient {
199
602
  * @param {string} namespace - The config namespace
200
603
  * @param {string} configKey - The config key
201
604
  * @param {any} configValue - Configuration value to set
605
+ * @param {string} [auditMessage] - Audit message for this write
606
+ * @param {{expected?: any}} [options] - `expected`: the value read for this key (null or undefined when it
607
+ * was absent). The write is then refused with a 409 when the stored value differs.
202
608
  */
203
- setConfig: (id, namespace, configKey, configValue, auditMessage) =>
204
- this._request('PUT', `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`, { auditMessage,
205
- rawBody: configValue, skipResponseTransform: true,
206
- }),
609
+ setConfig: (id, namespace, configKey, configValue, auditMessage, options) =>
610
+ this._request(
611
+ "PUT",
612
+ `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`,
613
+ configRequest(auditMessage, configValue, options),
614
+ ),
207
615
  /**
208
616
  * Remove a configuration value for a layer.
209
617
  * @param {string} id - The resource ID
210
618
  * @param {string} namespace - The config namespace
211
619
  * @param {string} configKey - The config key
620
+ * @param {string} [auditMessage] - Audit message for this write
621
+ * @param {{expected?: any}} [options] - `expected`, as on setConfig.
212
622
  */
213
- deleteConfig: (id, namespace, configKey, auditMessage) =>
214
- this._request('DELETE', `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`, { auditMessage,
215
- skipResponseTransform: true,
216
- }),
623
+ deleteConfig: (id, namespace, configKey, auditMessage, options) =>
624
+ this._request(
625
+ "DELETE",
626
+ `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`,
627
+ configRequest(auditMessage, undefined, options, true),
628
+ ),
217
629
  /**
218
630
  * List all vocab layers accessible to user. Transparently follows
219
631
  * pagination cursors and returns the full flat array.
220
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
221
- * @param {string} [asOf] - Temporal query timestamp
222
632
  */
223
- list: (asOf) =>
224
- listAll(this, '/api/v1/vocab-layers', { query: { 'as-of': asOf } }),
633
+ list: () => listAll(this, "/api/v1/vocab-layers"),
225
634
  /**
226
635
  * Fetch a single page of vocab layers.
227
636
  * @param {object} [opts]
228
637
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
229
638
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
230
- * @param {string} [opts.asOf] - Temporal query timestamp
231
639
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
232
640
  */
233
- listPage: ({ limit, cursor, asOf } = {}) =>
234
- listPage(this, '/api/v1/vocab-layers', { limit, cursor, query: { 'as-of': asOf } }),
641
+ listPage: ({ limit, cursor } = {}) =>
642
+ listPage(this, "/api/v1/vocab-layers", {
643
+ limit,
644
+ cursor,
645
+ }),
235
646
  /**
236
647
  * Async-iterate vocab layers page by page; yields each page's entries array.
237
648
  * @param {object} [opts]
238
649
  * @param {number} [opts.pageSize] - Per-request page size
239
- * @param {string} [opts.asOf] - Temporal query timestamp
240
- * 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.
241
650
  * @returns {AsyncGenerator<Array>}
242
651
  */
243
- iterPages: ({ pageSize, asOf } = {}) =>
244
- iterPages(this, '/api/v1/vocab-layers', { pageSize, query: { 'as-of': asOf } }),
652
+ iterPages: ({ pageSize } = {}) =>
653
+ iterPages(this, "/api/v1/vocab-layers", {
654
+ pageSize,
655
+ }),
245
656
  /**
246
657
  * Create a new vocab layer. Note: this also registers the user as a maintainer.
247
658
  * @param {string} name - The name
248
659
  */
249
- create: (name, auditMessage) =>
250
- this._request('POST', '/api/v1/vocab-layers', { auditMessage,
251
- body: bodyOf({ name }),
660
+ create: (name, auditMessage, { id } = {}) =>
661
+ this._request("POST", "/api/v1/vocab-layers", {
662
+ auditMessage,
663
+ body: bodyOf({ id, name }),
252
664
  }),
253
665
  /**
254
666
  * Assign a user as a maintainer for this vocab layer.
@@ -256,14 +668,22 @@ class PlaidClient {
256
668
  * @param {string} userId - The user ID
257
669
  */
258
670
  addMaintainer: (id, userId, auditMessage) =>
259
- this._request('POST', `/api/v1/vocab-layers/${id}/maintainers/${userId}`, { auditMessage }),
671
+ this._request(
672
+ "POST",
673
+ `/api/v1/vocab-layers/${id}/maintainers/${userId}`,
674
+ { auditMessage },
675
+ ),
260
676
  /**
261
677
  * Remove a user's maintainer privileges for this vocab layer.
262
678
  * @param {string} id - The resource ID
263
679
  * @param {string} userId - The user ID
264
680
  */
265
681
  removeMaintainer: (id, userId, auditMessage) =>
266
- this._request('DELETE', `/api/v1/vocab-layers/${id}/maintainers/${userId}`, { auditMessage }),
682
+ this._request(
683
+ "DELETE",
684
+ `/api/v1/vocab-layers/${id}/maintainers/${userId}`,
685
+ { auditMessage },
686
+ ),
267
687
  };
268
688
 
269
689
  this.relations = {
@@ -273,25 +693,27 @@ class PlaidClient {
273
693
  * @param {any} body - The request body
274
694
  */
275
695
  setMetadata: (relationId, body, auditMessage) =>
276
- this._request('PUT', `/api/v1/relations/${relationId}/metadata`, { auditMessage,
277
- rawBody: body, skipResponseTransform: true,
696
+ this._request("PUT", `/api/v1/relations/${relationId}/metadata`, {
697
+ auditMessage,
698
+ rawBody: body,
278
699
  }),
279
700
  /**
280
701
  * Remove all metadata from a relation.
281
702
  * @param {string} relationId - The relation ID
282
703
  */
283
704
  deleteMetadata: (relationId, auditMessage) =>
284
- this._request('DELETE', `/api/v1/relations/${relationId}/metadata`, { auditMessage,
285
- skipResponseTransform: true,
705
+ this._request("DELETE", `/api/v1/relations/${relationId}/metadata`, {
706
+ auditMessage,
286
707
  }),
287
708
  /**
288
- * Patch (shallow-merge) metadata for a relation. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
709
+ * Edit metadata for a relation with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
289
710
  * @param {string} relationId - The relation ID
290
- * @param {any} body - The metadata patch
711
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
291
712
  */
292
713
  patchMetadata: (relationId, body, auditMessage) =>
293
- this._request('PATCH', `/api/v1/relations/${relationId}/metadata`, { auditMessage,
294
- rawBody: body, skipResponseTransform: true,
714
+ this._request("PATCH", `/api/v1/relations/${relationId}/metadata`, {
715
+ auditMessage,
716
+ rawBody: body,
295
717
  }),
296
718
  /**
297
719
  * Update the target span of a relation.
@@ -299,31 +721,32 @@ class PlaidClient {
299
721
  * @param {string} spanId - The span ID
300
722
  */
301
723
  setTarget: (relationId, spanId, auditMessage) =>
302
- this._request('PUT', `/api/v1/relations/${relationId}/target`, { auditMessage,
303
- body: bodyOf({ 'span-id': spanId }),
724
+ this._request("PUT", `/api/v1/relations/${relationId}/target`, {
725
+ auditMessage,
726
+ body: bodyOf({ "span-id": spanId }),
304
727
  }),
305
728
  /**
306
729
  * Get a relation by ID.
307
730
  * @param {string} relationId - The relation ID
308
- * @param {string} [asOf] - Temporal query timestamp
309
731
  */
310
- get: (relationId, asOf) =>
311
- this._request('GET', `/api/v1/relations/${relationId}`, {
312
- queryParams: { 'as-of': asOf },
313
- }),
732
+ get: (relationId) =>
733
+ this._request("GET", `/api/v1/relations/${relationId}`),
314
734
  /**
315
735
  * Delete a relation.
316
736
  * @param {string} relationId - The relation ID
317
737
  */
318
738
  delete: (relationId, auditMessage) =>
319
- this._request('DELETE', `/api/v1/relations/${relationId}`, { auditMessage }),
739
+ this._request("DELETE", `/api/v1/relations/${relationId}`, {
740
+ auditMessage,
741
+ }),
320
742
  /**
321
743
  * Update a relation's value.
322
744
  * @param {string} relationId - The relation ID
323
745
  * @param {any} value - The value
324
746
  */
325
747
  update: (relationId, value, auditMessage) =>
326
- this._request('PATCH', `/api/v1/relations/${relationId}`, { auditMessage,
748
+ this._request("PATCH", `/api/v1/relations/${relationId}`, {
749
+ auditMessage,
327
750
  body: bodyOf({ value }),
328
751
  }),
329
752
  /**
@@ -332,8 +755,9 @@ class PlaidClient {
332
755
  * @param {string} spanId - The span ID
333
756
  */
334
757
  setSource: (relationId, spanId, auditMessage) =>
335
- this._request('PUT', `/api/v1/relations/${relationId}/source`, { auditMessage,
336
- body: bodyOf({ 'span-id': spanId }),
758
+ this._request("PUT", `/api/v1/relations/${relationId}/source`, {
759
+ auditMessage,
760
+ body: bodyOf({ "span-id": spanId }),
337
761
  }),
338
762
  /**
339
763
  * Create a new relation. A relation is a directed edge between two spans
@@ -345,9 +769,25 @@ class PlaidClient {
345
769
  * @param {any} value - The value
346
770
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
347
771
  */
348
- create: (layerId, sourceId, targetId, value, metadata, auditMessage) =>
349
- this._request('POST', '/api/v1/relations', { auditMessage,
350
- body: bodyOf({ 'layer-id': layerId, 'source-id': sourceId, 'target-id': targetId, value, metadata }),
772
+ create: (
773
+ layerId,
774
+ sourceId,
775
+ targetId,
776
+ value,
777
+ metadata,
778
+ auditMessage,
779
+ { id } = {},
780
+ ) =>
781
+ this._request("POST", "/api/v1/relations", {
782
+ auditMessage,
783
+ body: bodyOf({
784
+ id,
785
+ "layer-id": layerId,
786
+ "source-id": sourceId,
787
+ "target-id": targetId,
788
+ value,
789
+ metadata,
790
+ }),
351
791
  }),
352
792
  /**
353
793
  * Create multiple relations in a single operation.
@@ -355,13 +795,26 @@ class PlaidClient {
355
795
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
356
796
  */
357
797
  bulkCreate: (body, auditMessage) =>
358
- this._request('POST', '/api/v1/relations/bulk', { auditMessage, body }),
798
+ this._request("POST", "/api/v1/relations/bulk", { auditMessage, body }),
359
799
  /**
360
800
  * Delete multiple relations in a single operation. Provide an array of IDs.
361
801
  * @param {Array} body - The request body
362
802
  */
363
803
  bulkDelete: (body, auditMessage) =>
364
- this._request('DELETE', '/api/v1/relations/bulk', { auditMessage, body }),
804
+ this._request("DELETE", "/api/v1/relations/bulk", {
805
+ auditMessage,
806
+ body,
807
+ }),
808
+ /**
809
+ * Update many relations in a single operation: set values and/or patch metadata.
810
+ * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a list of metadata ops, as for patchMetadata. The relations may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions` (past fifty documents, only their number, in `X-Document-Versions-Omitted`). A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
811
+ * @returns {Promise<{count: number}>} How many relations were updated.
812
+ */
813
+ bulkUpdate: (body, auditMessage) =>
814
+ this._request("PATCH", "/api/v1/relations/bulk", {
815
+ auditMessage,
816
+ body,
817
+ }),
365
818
  };
366
819
 
367
820
  this.spanLayers = {
@@ -371,43 +824,53 @@ class PlaidClient {
371
824
  * @param {string} namespace - The config namespace
372
825
  * @param {string} configKey - The config key
373
826
  * @param {any} configValue - Configuration value to set
827
+ * @param {string} [auditMessage] - Audit message for this write
828
+ * @param {{expected?: any}} [options] - `expected`: the value read for this key (null or undefined when it
829
+ * was absent). The write is then refused with a 409 when the stored value differs.
374
830
  */
375
- setConfig: (spanLayerId, namespace, configKey, configValue, auditMessage) =>
376
- this._request('PUT', `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`, { auditMessage,
377
- rawBody: configValue, skipResponseTransform: true,
378
- }),
831
+ setConfig: (spanLayerId, namespace, configKey, configValue, auditMessage, options) =>
832
+ this._request(
833
+ "PUT",
834
+ `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`,
835
+ configRequest(auditMessage, configValue, options),
836
+ ),
379
837
  /**
380
838
  * Remove a configuration value for a layer.
381
839
  * @param {string} spanLayerId - The span layer ID
382
840
  * @param {string} namespace - The config namespace
383
841
  * @param {string} configKey - The config key
842
+ * @param {string} [auditMessage] - Audit message for this write
843
+ * @param {{expected?: any}} [options] - `expected`, as on setConfig.
384
844
  */
385
- deleteConfig: (spanLayerId, namespace, configKey, auditMessage) =>
386
- this._request('DELETE', `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`, { auditMessage,
387
- skipResponseTransform: true,
388
- }),
845
+ deleteConfig: (spanLayerId, namespace, configKey, auditMessage, options) =>
846
+ this._request(
847
+ "DELETE",
848
+ `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`,
849
+ configRequest(auditMessage, undefined, options, true),
850
+ ),
851
+ ...constraintMethods(this, "span-layers"),
389
852
  /**
390
853
  * Get a span layer by ID.
391
854
  * @param {string} spanLayerId - The span layer ID
392
- * @param {string} [asOf] - Temporal query timestamp
393
855
  */
394
- get: (spanLayerId, asOf) =>
395
- this._request('GET', `/api/v1/span-layers/${spanLayerId}`, {
396
- queryParams: { 'as-of': asOf },
397
- }),
856
+ get: (spanLayerId) =>
857
+ this._request("GET", `/api/v1/span-layers/${spanLayerId}`),
398
858
  /**
399
859
  * Delete a span layer.
400
860
  * @param {string} spanLayerId - The span layer ID
401
861
  */
402
862
  delete: (spanLayerId, auditMessage) =>
403
- this._request('DELETE', `/api/v1/span-layers/${spanLayerId}`, { auditMessage }),
863
+ this._request("DELETE", `/api/v1/span-layers/${spanLayerId}`, {
864
+ auditMessage,
865
+ }),
404
866
  /**
405
867
  * Update a span layer's name.
406
868
  * @param {string} spanLayerId - The span layer ID
407
869
  * @param {string} name - The name
408
870
  */
409
871
  update: (spanLayerId, name, auditMessage) =>
410
- this._request('PATCH', `/api/v1/span-layers/${spanLayerId}`, { auditMessage,
872
+ this._request("PATCH", `/api/v1/span-layers/${spanLayerId}`, {
873
+ auditMessage,
411
874
  body: bodyOf({ name }),
412
875
  }),
413
876
  /**
@@ -415,9 +878,10 @@ class PlaidClient {
415
878
  * @param {string} tokenLayerId - The token layer ID
416
879
  * @param {string} name - The name
417
880
  */
418
- create: (tokenLayerId, name, auditMessage) =>
419
- this._request('POST', '/api/v1/span-layers', { auditMessage,
420
- body: bodyOf({ 'token-layer-id': tokenLayerId, name }),
881
+ create: (tokenLayerId, name, auditMessage, { id } = {}) =>
882
+ this._request("POST", "/api/v1/span-layers", {
883
+ auditMessage,
884
+ body: bodyOf({ id, "token-layer-id": tokenLayerId, name }),
421
885
  }),
422
886
  /**
423
887
  * Shift a span layer's display order.
@@ -425,7 +889,8 @@ class PlaidClient {
425
889
  * @param {string} direction - The direction ("up" or "down")
426
890
  */
427
891
  shift: (spanLayerId, direction, auditMessage) =>
428
- this._request('POST', `/api/v1/span-layers/${spanLayerId}/shift`, { auditMessage,
892
+ this._request("POST", `/api/v1/span-layers/${spanLayerId}/shift`, {
893
+ auditMessage,
429
894
  body: bodyOf({ direction }),
430
895
  }),
431
896
  };
@@ -437,7 +902,8 @@ class PlaidClient {
437
902
  * @param {Array} tokens - The tokens
438
903
  */
439
904
  setTokens: (spanId, tokens, auditMessage) =>
440
- this._request('PUT', `/api/v1/spans/${spanId}/tokens`, { auditMessage,
905
+ this._request("PUT", `/api/v1/spans/${spanId}/tokens`, {
906
+ auditMessage,
441
907
  body: bodyOf({ tokens }),
442
908
  }),
443
909
  /**
@@ -448,32 +914,37 @@ class PlaidClient {
448
914
  * @param {any} value - The value
449
915
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
450
916
  */
451
- create: (spanLayerId, tokens, value, metadata, auditMessage) =>
452
- this._request('POST', '/api/v1/spans', { auditMessage,
453
- body: bodyOf({ 'span-layer-id': spanLayerId, tokens, value, metadata }),
917
+ create: (spanLayerId, tokens, value, metadata, auditMessage, { id } = {}) =>
918
+ this._request("POST", "/api/v1/spans", {
919
+ auditMessage,
920
+ body: bodyOf({
921
+ id,
922
+ "span-layer-id": spanLayerId,
923
+ tokens,
924
+ value,
925
+ metadata,
926
+ }),
454
927
  }),
455
928
  /**
456
929
  * Get a span by ID.
457
930
  * @param {string} spanId - The span ID
458
- * @param {string} [asOf] - Temporal query timestamp
459
931
  */
460
- get: (spanId, asOf) =>
461
- this._request('GET', `/api/v1/spans/${spanId}`, {
462
- queryParams: { 'as-of': asOf },
463
- }),
932
+ get: (spanId) =>
933
+ this._request("GET", `/api/v1/spans/${spanId}`),
464
934
  /**
465
935
  * Delete a span.
466
936
  * @param {string} spanId - The span ID
467
937
  */
468
938
  delete: (spanId, auditMessage) =>
469
- this._request('DELETE', `/api/v1/spans/${spanId}`, { auditMessage }),
939
+ this._request("DELETE", `/api/v1/spans/${spanId}`, { auditMessage }),
470
940
  /**
471
941
  * Update a span's value.
472
942
  * @param {string} spanId - The span ID
473
943
  * @param {any} value - The value
474
944
  */
475
945
  update: (spanId, value, auditMessage) =>
476
- this._request('PATCH', `/api/v1/spans/${spanId}`, { auditMessage,
946
+ this._request("PATCH", `/api/v1/spans/${spanId}`, {
947
+ auditMessage,
477
948
  body: bodyOf({ value }),
478
949
  }),
479
950
  /**
@@ -482,51 +953,101 @@ class PlaidClient {
482
953
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
483
954
  */
484
955
  bulkCreate: (body, auditMessage) =>
485
- this._request('POST', '/api/v1/spans/bulk', { auditMessage, body }),
956
+ this._request("POST", "/api/v1/spans/bulk", { auditMessage, body }),
486
957
  /**
487
958
  * Delete multiple spans in a single operation. Provide an array of IDs.
488
959
  * @param {Array} body - The request body
489
960
  */
490
961
  bulkDelete: (body, auditMessage) =>
491
- this._request('DELETE', '/api/v1/spans/bulk', { auditMessage, body }),
962
+ this._request("DELETE", "/api/v1/spans/bulk", { auditMessage, body }),
963
+ /**
964
+ * Update many spans in a single operation: set values and/or patch metadata.
965
+ * @param {Array} body - Objects of the shape `{id, value?, metadata?}`. `value` is set only when the key is present (null sends JSON null); `metadata` is a list of metadata ops, as for patchMetadata. The spans may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions` (past fifty documents, only their number, in `X-Document-Versions-Omitted`). A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
966
+ * @returns {Promise<{count: number}>} How many spans were updated.
967
+ */
968
+ bulkUpdate: (body, auditMessage) =>
969
+ this._request("PATCH", "/api/v1/spans/bulk", {
970
+ auditMessage,
971
+ body,
972
+ }),
492
973
  /**
493
974
  * Replace all metadata for a span.
494
975
  * @param {string} spanId - The span ID
495
976
  * @param {any} body - The request body
496
977
  */
497
978
  setMetadata: (spanId, body, auditMessage) =>
498
- this._request('PUT', `/api/v1/spans/${spanId}/metadata`, { auditMessage,
499
- rawBody: body, skipResponseTransform: true,
979
+ this._request("PUT", `/api/v1/spans/${spanId}/metadata`, {
980
+ auditMessage,
981
+ rawBody: body,
500
982
  }),
501
983
  /**
502
984
  * Remove all metadata from a span.
503
985
  * @param {string} spanId - The span ID
504
986
  */
505
987
  deleteMetadata: (spanId, auditMessage) =>
506
- this._request('DELETE', `/api/v1/spans/${spanId}/metadata`, { auditMessage,
507
- skipResponseTransform: true,
988
+ this._request("DELETE", `/api/v1/spans/${spanId}/metadata`, {
989
+ auditMessage,
508
990
  }),
509
991
  /**
510
- * Patch (shallow-merge) metadata for a span. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
992
+ * Edit metadata for a span with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
511
993
  * @param {string} spanId - The span ID
512
- * @param {any} body - The metadata patch
994
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
513
995
  */
514
996
  patchMetadata: (spanId, body, auditMessage) =>
515
- this._request('PATCH', `/api/v1/spans/${spanId}/metadata`, { auditMessage,
516
- rawBody: body, skipResponseTransform: true,
997
+ this._request("PATCH", `/api/v1/spans/${spanId}/metadata`, {
998
+ auditMessage,
999
+ rawBody: body,
517
1000
  }),
518
1001
  };
519
1002
 
520
- this.batch = {
1003
+ // Server-level facts. Fetched at most once: the limits cannot change while
1004
+ // the server is up, and a caller asking "will this file be accepted" should
1005
+ // not pay a round trip to find out.
1006
+ let infoPromise = null;
1007
+ this.server = {
1008
+ /**
1009
+ * This server's version and the limits it enforces, e.g.
1010
+ * `{ limits: { mediaFileBytes, jsonBodyBytes, batchOperations, … } }`.
1011
+ * Sizes are in bytes. Unauthenticated.
1012
+ */
1013
+ info: () => {
1014
+ if (!infoPromise) {
1015
+ infoPromise = this._request("GET", "/api/v1/info").catch((err) => {
1016
+ // A failure must not be cached: a client that starts before the
1017
+ // server is up would never see the limits at all.
1018
+ infoPromise = null;
1019
+ throw err;
1020
+ });
1021
+ }
1022
+ return infoPromise;
1023
+ },
1024
+ /** Just the limits, which is what a caller almost always wants. */
1025
+ limits: async () => (await this.server.info()).limits,
521
1026
  /**
522
- * Execute multiple API operations atomically. If any operation fails, all
523
- * changes are rolled back.
524
- * @param {Array} body - The request body
1027
+ * Liveness, version and database size. Unauthenticated, and served
1028
+ * outside the REST router at /health, so it answers even while the API
1029
+ * is refusing requests. Never cached — the point is that it is current.
1030
+ * @returns {Promise<{ok: boolean, version: string, uptimeMs: number, audit: object}>}
525
1031
  */
526
- submit: (body, auditMessage) =>
527
- this._request('POST', '/api/v1/batch', { auditMessage,
528
- body, noBatch: true,
529
- }),
1032
+ health: () => PlaidClient.health(this.baseUrl, { timeout: this.timeout }),
1033
+ };
1034
+
1035
+ // The signed-in user's sign-ins. Signing in is `PlaidClient.login`, which
1036
+ // makes the client.
1037
+ this.auth = {
1038
+ /**
1039
+ * End every sign-in of this user on every device: each browser tab,
1040
+ * script and service holding one of the user's sign-in tokens is
1041
+ * refused from now on, this client included. Named API tokens are not
1042
+ * affected (revoke one with `apiTokens.revoke`). To sign out of one tab
1043
+ * only, discard its token instead. A signal rather than project data:
1044
+ * made on a batch it still goes out at once, and it never joins an
1045
+ * operation.
1046
+ * @returns {Promise<void>}
1047
+ */
1048
+ logoutEverywhere: async () => {
1049
+ await this._request("POST", "/api/v1/logout", { outOfBand: true });
1050
+ },
530
1051
  };
531
1052
 
532
1053
  this.texts = {
@@ -536,25 +1057,27 @@ class PlaidClient {
536
1057
  * @param {any} body - The request body
537
1058
  */
538
1059
  setMetadata: (textId, body, auditMessage) =>
539
- this._request('PUT', `/api/v1/texts/${textId}/metadata`, { auditMessage,
540
- rawBody: body, skipResponseTransform: true,
1060
+ this._request("PUT", `/api/v1/texts/${textId}/metadata`, {
1061
+ auditMessage,
1062
+ rawBody: body,
541
1063
  }),
542
1064
  /**
543
1065
  * Remove all metadata from a text.
544
1066
  * @param {string} textId - The text ID
545
1067
  */
546
1068
  deleteMetadata: (textId, auditMessage) =>
547
- this._request('DELETE', `/api/v1/texts/${textId}/metadata`, { auditMessage,
548
- skipResponseTransform: true,
1069
+ this._request("DELETE", `/api/v1/texts/${textId}/metadata`, {
1070
+ auditMessage,
549
1071
  }),
550
1072
  /**
551
- * Patch (shallow-merge) metadata for a text. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
1073
+ * Edit metadata for a text with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
552
1074
  * @param {string} textId - The text ID
553
- * @param {any} body - The metadata patch
1075
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
554
1076
  */
555
1077
  patchMetadata: (textId, body, auditMessage) =>
556
- this._request('PATCH', `/api/v1/texts/${textId}/metadata`, { auditMessage,
557
- rawBody: body, skipResponseTransform: true,
1078
+ this._request("PATCH", `/api/v1/texts/${textId}/metadata`, {
1079
+ auditMessage,
1080
+ rawBody: body,
558
1081
  }),
559
1082
  /**
560
1083
  * Create a new text in a document's text layer. A text is a container for
@@ -564,35 +1087,84 @@ class PlaidClient {
564
1087
  * @param {string} body - The request body
565
1088
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
566
1089
  */
567
- create: (textLayerId, documentId, body, metadata, auditMessage) =>
568
- this._request('POST', '/api/v1/texts', { auditMessage,
569
- body: bodyOf({ 'text-layer-id': textLayerId, 'document-id': documentId, body, metadata }),
1090
+ create: (
1091
+ textLayerId,
1092
+ documentId,
1093
+ body,
1094
+ metadata,
1095
+ auditMessage,
1096
+ { id } = {},
1097
+ ) =>
1098
+ this._request("POST", "/api/v1/texts", {
1099
+ auditMessage,
1100
+ body: bodyOf({
1101
+ id,
1102
+ "text-layer-id": textLayerId,
1103
+ "document-id": documentId,
1104
+ body,
1105
+ metadata,
1106
+ }),
570
1107
  }),
571
1108
  /**
572
1109
  * Get a text.
573
1110
  * @param {string} textId - The text ID
574
- * @param {string} [asOf] - Temporal query timestamp
575
1111
  */
576
- get: (textId, asOf) =>
577
- this._request('GET', `/api/v1/texts/${textId}`, {
578
- queryParams: { 'as-of': asOf },
579
- }),
1112
+ get: (textId) =>
1113
+ this._request("GET", `/api/v1/texts/${textId}`),
580
1114
  /**
581
1115
  * Delete a text and all dependent data.
582
1116
  * @param {string} textId - The text ID
583
1117
  */
584
1118
  delete: (textId, auditMessage) =>
585
- this._request('DELETE', `/api/v1/texts/${textId}`, { auditMessage }),
1119
+ this._request("DELETE", `/api/v1/texts/${textId}`, { auditMessage }),
586
1120
  /**
587
1121
  * Update a text's body. A diff is computed and token indices are updated
588
1122
  * so that tokens remain intact. Alternatively, `body` can be a list of
589
- * edit directives.
1123
+ * edit directives (code-point indices, applied in order):
1124
+ * {type: 'delete', index, value: count}
1125
+ * {type: 'insert', index, value: string}
1126
+ * {type: 'replace', index, length, value: string} — unlike
1127
+ * delete+insert, a token covering the whole range is resized to keep
1128
+ * it (respell a word in place without losing its annotations).
1129
+ * A list is applied exactly as sent. With `base` (the `digest` of the
1130
+ * body the update was made on, as every read of a text gives it) the
1131
+ * update applies only to that body, and is refused with 409 and
1132
+ * `text-changed` otherwise. Strict mode then does not stamp it, unless
1133
+ * `versioned` is true (a batch whose later writes are stamped needs its
1134
+ * first write stamped too).
590
1135
  * @param {string} textId - The text ID
591
1136
  * @param {any} body - The request body
592
- */
593
- update: (textId, body, auditMessage) =>
594
- this._request('PATCH', `/api/v1/texts/${textId}`, { auditMessage,
595
- body: bodyOf({ body }),
1137
+ * @param {string} [auditMessage]
1138
+ * @param {{base?: string, versioned?: boolean}} [options]
1139
+ */
1140
+ update: (textId, body, auditMessage, { base, versioned } = {}) =>
1141
+ this._request("PATCH", `/api/v1/texts/${textId}`, {
1142
+ auditMessage,
1143
+ body: bodyOf({ body, base }),
1144
+ versioned: versioned ?? (base === undefined || base === null),
1145
+ }),
1146
+ /**
1147
+ * Change a text's body by the edits made at the caret: a list of edit
1148
+ * directives as `update` takes them (code-point indices, applied in
1149
+ * order, each index in the body the ones before it left). Only their
1150
+ * net change counts. An insert or a delete stays where it was made, and
1151
+ * a stretch deleted and typed over is read as a whole new body is. The
1152
+ * answer is the text with its new `digest` and `reshape` (the tokens
1153
+ * moved, the spans and vocab links trimmed, the rows deleted). With
1154
+ * `base`, the digest of the body the edits were made on, the edit
1155
+ * applies only to that body and is refused with 409 and `text-changed`
1156
+ * otherwise, and strict mode does not stamp it unless `versioned` is
1157
+ * true. See composeTextEdits and gapsToOps.
1158
+ * @param {string} textId - The text ID
1159
+ * @param {Array<object>} edits - The edit directives
1160
+ * @param {string} [auditMessage]
1161
+ * @param {{base?: string, versioned?: boolean}} [options]
1162
+ */
1163
+ edit: (textId, edits, auditMessage, { base, versioned } = {}) =>
1164
+ this._request("PATCH", `/api/v1/texts/${textId}`, {
1165
+ auditMessage,
1166
+ body: bodyOf({ edits, base }),
1167
+ versioned: versioned ?? (base === undefined || base === null),
596
1168
  }),
597
1169
  };
598
1170
 
@@ -600,76 +1172,113 @@ class PlaidClient {
600
1172
  /**
601
1173
  * List (or search) users. Transparently follows pagination cursors and
602
1174
  * returns the full flat array. Admin-or-maintainer only.
603
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
604
1175
  * @param {object} [opts]
605
- * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
606
- * @param {string} [opts.asOf] - Temporal query timestamp
1176
+ * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
607
1177
  */
608
- list: ({ q, asOf } = {}) =>
609
- listAll(this, '/api/v1/users', { query: { q, 'as-of': asOf } }),
1178
+ list: ({ q } = {}) =>
1179
+ listAll(this, "/api/v1/users", { query: { q } }),
610
1180
  /**
611
1181
  * Fetch a single page of users (optionally filtered by `q`).
612
1182
  * @param {object} [opts]
613
- * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
1183
+ * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
614
1184
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
615
1185
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
616
- * @param {string} [opts.asOf] - Temporal query timestamp
617
1186
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
618
1187
  */
619
- listPage: ({ q, limit, cursor, asOf } = {}) =>
620
- listPage(this, '/api/v1/users', { limit, cursor, query: { q, 'as-of': asOf } }),
1188
+ listPage: ({ q, limit, cursor } = {}) =>
1189
+ listPage(this, "/api/v1/users", {
1190
+ limit,
1191
+ cursor,
1192
+ query: { q },
1193
+ }),
621
1194
  /**
622
1195
  * Async-iterate users page by page; yields each page's entries array.
623
1196
  * @param {object} [opts]
624
- * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
1197
+ * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
625
1198
  * @param {number} [opts.pageSize] - Per-request page size
626
- * @param {string} [opts.asOf] - Temporal query timestamp
627
- * 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.
628
1199
  * @returns {AsyncGenerator<Array>}
629
1200
  */
630
- iterPages: ({ q, pageSize, asOf } = {}) =>
631
- iterPages(this, '/api/v1/users', { pageSize, query: { q, 'as-of': asOf } }),
1201
+ iterPages: ({ q, pageSize } = {}) =>
1202
+ iterPages(this, "/api/v1/users", {
1203
+ pageSize,
1204
+ query: { q },
1205
+ }),
632
1206
  /**
633
- * Create a new user
634
- * @param {string} username - The username
1207
+ * Create a new user. Admin only, and not with a named API token (403):
1208
+ * it needs a sign-in token.
1209
+ * @param {string} email - The account's email address. It becomes the
1210
+ * user's id and is what they log in with; it can never be changed.
635
1211
  * @param {string} password - The password
636
1212
  * @param {boolean} isAdmin - Whether the user is an admin
1213
+ * @param {string} [displayName] - How the user is shown in the UI.
1214
+ * Defaults to the local part of the email.
637
1215
  */
638
- create: (username, password, isAdmin, auditMessage) =>
639
- this._request('POST', '/api/v1/users', { auditMessage,
640
- body: bodyOf({ username, password, 'is-admin': isAdmin }),
1216
+ create: (email, password, isAdmin, displayName, auditMessage) =>
1217
+ this._request("POST", "/api/v1/users", {
1218
+ auditMessage,
1219
+ body: bodyOf({
1220
+ email,
1221
+ password,
1222
+ "is-admin": isAdmin,
1223
+ // Left out when not given (null too), so core gives the user the
1224
+ // local part of the email. Core refuses a null display name.
1225
+ "display-name": displayName ?? undefined,
1226
+ }),
641
1227
  }),
642
1228
  /**
643
1229
  * Get audit log for a user's actions. Transparently follows pagination
644
1230
  * cursors and returns the full flat array.
645
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
646
1231
  * @param {string} userId - The user ID
647
1232
  * @param {string} [startTime] - Start of time range
648
1233
  * @param {string} [endTime] - End of time range
649
- * @param {string} [asOf] - Temporal query timestamp
650
1234
  * @param {string[]|string} [opTypes] - Only return operations of these
651
1235
  * types, spelled as in an entry's `op/type` (e.g.
652
1236
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
653
1237
  * one of its operations matches, carrying only the ones that did.
1238
+ * @param {string[]|string} [kinds] - Only the entries of operations of
1239
+ * these kinds (e.g. `['review', 'guess-adoption']`), each whole
654
1240
  */
655
- audit: (userId, startTime, endTime, asOf, opTypes) =>
1241
+ audit: (userId, startTime, endTime, opTypes, kinds) =>
656
1242
  listAll(this, `/api/v1/users/${userId}/audit`, {
657
1243
  query: {
658
- 'start-time': startTime,
659
- 'end-time': endTime,
660
- 'as-of': asOf,
661
- 'op-types': opTypesParam(opTypes),
1244
+ "start-time": startTime,
1245
+ "end-time": endTime,
1246
+ "op-types": opTypesParam(opTypes),
1247
+ kinds: kindsParam(kinds),
1248
+ },
1249
+ }),
1250
+ /**
1251
+ * One page of the same log, newest-first with `order: "desc"`. Use this
1252
+ * rather than audit() wherever the caller wants the recent end of a log
1253
+ * that may be long: audit() walks every page before it resolves.
1254
+ * @param {string} userId - The user ID
1255
+ * @param {object} [opts]
1256
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
1257
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1258
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1259
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1260
+ */
1261
+ auditPage: (
1262
+ userId,
1263
+ { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
1264
+ ) =>
1265
+ listPage(this, `/api/v1/users/${userId}/audit`, {
1266
+ limit,
1267
+ cursor,
1268
+ query: {
1269
+ "start-time": startTime,
1270
+ "end-time": endTime,
1271
+ "op-types": opTypesParam(opTypes),
1272
+ kinds: kindsParam(kinds),
1273
+ order,
662
1274
  },
663
1275
  }),
664
1276
  /**
665
1277
  * Get a user by ID
666
1278
  * @param {string} id - The resource ID
667
- * @param {string} [asOf] - Temporal query timestamp
668
1279
  */
669
- get: (id, asOf) =>
670
- this._request('GET', `/api/v1/users/${id}`, {
671
- queryParams: { 'as-of': asOf },
672
- }),
1280
+ get: (id) =>
1281
+ this._request("GET", `/api/v1/users/${id}`),
673
1282
  /**
674
1283
  * Deactivate a user. Users are never hard-deleted: deactivation
675
1284
  * rejects their logins and tokens, strips their project memberships
@@ -679,7 +1288,7 @@ class PlaidClient {
679
1288
  * @param {string} id - The resource ID
680
1289
  */
681
1290
  delete: (id, auditMessage) =>
682
- this._request('DELETE', `/api/v1/users/${id}`, { auditMessage }),
1291
+ this._request("DELETE", `/api/v1/users/${id}`, { auditMessage }),
683
1292
  /**
684
1293
  * Reactivate a deactivated user, restoring their ability to log in.
685
1294
  * Project memberships, vocab maintainerships, and API tokens removed
@@ -687,19 +1296,28 @@ class PlaidClient {
687
1296
  * @param {string} id - The resource ID
688
1297
  */
689
1298
  activate: (id, auditMessage) =>
690
- this._request('POST', `/api/v1/users/${id}/activate`, { auditMessage }),
1299
+ this._request("POST", `/api/v1/users/${id}/activate`, { auditMessage }),
691
1300
  /**
692
- * Modify a user. Admins may change the username, password, and admin
693
- * status of any user. All other users may only modify their own username
694
- * or password.
695
- * @param {string} id - The resource ID
1301
+ * Modify a user. Admins may change the display name, password, and admin
1302
+ * status of any user. All other users may only modify their own display
1303
+ * name or password. A password change cannot be made with a named API
1304
+ * token (403): it needs a sign-in token.
1305
+ *
1306
+ * A user's id is their email address and is fixed for the life of the
1307
+ * account — it is what they log in with, so nothing can change it.
1308
+ * @param {string} id - The resource ID (the user's email address)
696
1309
  * @param {string} [password] - New password
697
- * @param {string} [username] - New username
1310
+ * @param {string} [displayName] - New display name
698
1311
  * @param {boolean} [isAdmin] - New admin status
699
1312
  */
700
- update: (id, password, username, isAdmin, auditMessage) =>
701
- this._request('PATCH', `/api/v1/users/${id}`, { auditMessage,
702
- body: bodyOf({ password, username, 'is-admin': isAdmin }),
1313
+ update: (id, password, displayName, isAdmin, auditMessage) =>
1314
+ this._request("PATCH", `/api/v1/users/${id}`, {
1315
+ auditMessage,
1316
+ body: bodyOf({
1317
+ password,
1318
+ "display-name": displayName,
1319
+ "is-admin": isAdmin,
1320
+ }),
703
1321
  }),
704
1322
  /**
705
1323
  * Build a URL for a user's profile picture, suitable for use directly as
@@ -719,7 +1337,7 @@ class PlaidClient {
719
1337
  avatarUrl: (id, avatarHash) => {
720
1338
  if (avatarHash === null) return null;
721
1339
  const params = new URLSearchParams({ token: this.token });
722
- if (avatarHash) params.set('v', avatarHash);
1340
+ if (avatarHash) params.set("v", avatarHash);
723
1341
  return `${this.baseUrl}/api/v1/users/${id}/avatar?${params}`;
724
1342
  },
725
1343
  /**
@@ -728,8 +1346,7 @@ class PlaidClient {
728
1346
  * @param {string} id - The user ID
729
1347
  */
730
1348
  getAvatar: (id) =>
731
- this._request('GET', `/api/v1/users/${id}/avatar`, {
732
- noBatch: true,
1349
+ this._request("GET", `/api/v1/users/${id}/avatar`, {
733
1350
  binaryResponse: true,
734
1351
  }),
735
1352
  /**
@@ -742,19 +1359,137 @@ class PlaidClient {
742
1359
  */
743
1360
  setAvatar: (id, file, auditMessage) => {
744
1361
  const fd = new FormData();
745
- fd.append('file', file);
746
- return this._request('PUT', `/api/v1/users/${id}/avatar`, { auditMessage,
747
- body: fd, formData: true, noBatch: true,
1362
+ fd.append("file", file);
1363
+ return this._request("PUT", `/api/v1/users/${id}/avatar`, {
1364
+ auditMessage,
1365
+ body: fd,
1366
+ formData: true,
1367
+ noBatch: true,
748
1368
  });
749
1369
  },
750
1370
  /**
751
1371
  * Remove a profile picture. Your own, or anyone's if you are an admin.
752
1372
  * @param {string} id - The user ID
753
1373
  */
1374
+ // No flag: the upload beside this one is multipart and cannot be
1375
+ // batched, but a DELETE carries no blob, so the batch transport takes
1376
+ // it. It is an ordinary audited write and queues like any other.
754
1377
  deleteAvatar: (id, auditMessage) =>
755
- this._request('DELETE', `/api/v1/users/${id}/avatar`, { auditMessage,
756
- noBatch: true,
1378
+ this._request("DELETE", `/api/v1/users/${id}/avatar`, {
1379
+ auditMessage,
1380
+ }),
1381
+ };
1382
+
1383
+ this.userData = {
1384
+ /**
1385
+ * List a user's private data entries ({key, updatedAt}, plus value when
1386
+ * includeValues), ordered by key. Owner or admin only. Transparently
1387
+ * follows pagination cursors and returns the full flat array.
1388
+ *
1389
+ * Narrow with `prefix` (the literal head of a key) and/or `pattern`, a
1390
+ * GLOB over the whole key (`*` any run, `?` one character) — the way to
1391
+ * ask for a key convention identified by a segment in the middle, e.g.
1392
+ * `igt:assistant:*:meta:*` for every conversation's sidebar entry across
1393
+ * every project without dragging down the transcripts beside them.
1394
+ *
1395
+ * `pageSize` is exposed here, and defaults lower than elsewhere, because
1396
+ * one value runs to 1 MB: a page of them with `includeValues` is the
1397
+ * largest response this API can be asked for. Raise it when the listing
1398
+ * is keys, or the values are known to be small.
1399
+ * @param {string} userId
1400
+ * @param {object} [opts]
1401
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
1402
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1403
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1404
+ * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1405
+ */
1406
+ list: (userId, { prefix, pattern, includeValues, pageSize = 100 } = {}) =>
1407
+ listAll(this, `/api/v1/users/${userId}/data`, {
1408
+ pageSize,
1409
+ query: { prefix, pattern, "include-values": includeValues },
757
1410
  }),
1411
+ /**
1412
+ * One page of a user's private data entries, ordered by key.
1413
+ * @param {string} userId
1414
+ * @param {object} [opts]
1415
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
1416
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1417
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1418
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1419
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1420
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1421
+ */
1422
+ listPage: (
1423
+ userId,
1424
+ { prefix, pattern, includeValues, limit, cursor } = {},
1425
+ ) =>
1426
+ listPage(this, `/api/v1/users/${userId}/data`, {
1427
+ limit,
1428
+ cursor,
1429
+ query: { prefix, pattern, "include-values": includeValues },
1430
+ }),
1431
+ /**
1432
+ * Async generator over a user's private data entries, one page at a time.
1433
+ * @param {string} userId
1434
+ * @param {object} [opts]
1435
+ * @param {string} [opts.prefix] - Only keys starting with this prefix
1436
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1437
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1438
+ * @param {number} [opts.pageSize=100] - Entries per request (1..1000)
1439
+ */
1440
+ iterPages: (
1441
+ userId,
1442
+ { prefix, pattern, includeValues, pageSize = 100 } = {},
1443
+ ) =>
1444
+ iterPages(this, `/api/v1/users/${userId}/data`, {
1445
+ pageSize,
1446
+ query: { prefix, pattern, "include-values": includeValues },
1447
+ }),
1448
+ /**
1449
+ * Read one private data entry ({key, updatedAt, value}); 404 if absent.
1450
+ * @param {string} userId
1451
+ * @param {string} key
1452
+ */
1453
+ get: (userId, key) =>
1454
+ this._request(
1455
+ "GET",
1456
+ `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1457
+ ),
1458
+ /**
1459
+ * Create or replace one private data entry. `value` is any JSON (up to
1460
+ * 1 MB). Not audited, not batchable.
1461
+ *
1462
+ * The server stores it verbatim, but this client recases object keys on
1463
+ * the way out and back like any other body (`myKey` <-> `my-key`), so a
1464
+ * value whose keys are camelCase round-trips unchanged while one keyed
1465
+ * by arbitrary strings does not. Put such a map under a `metadata` key,
1466
+ * which both clients pass through untouched.
1467
+ * @param {string} userId
1468
+ * @param {string} key
1469
+ * @param {*} value
1470
+ */
1471
+ put: (userId, key, value) =>
1472
+ this._request(
1473
+ "PUT",
1474
+ `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1475
+ {
1476
+ body: value,
1477
+ noBatch: true,
1478
+ },
1479
+ ),
1480
+ /**
1481
+ * Delete one private data entry; 404 if absent.
1482
+ * @param {string} userId
1483
+ * @param {string} key
1484
+ */
1485
+ delete: (userId, key) =>
1486
+ this._request(
1487
+ "DELETE",
1488
+ `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1489
+ {
1490
+ noBatch: true,
1491
+ },
1492
+ ),
758
1493
  };
759
1494
 
760
1495
  this.apiTokens = {
@@ -762,11 +1497,9 @@ class PlaidClient {
762
1497
  * List a user's named API tokens. Never includes the signed token
763
1498
  * string itself — that is only returned once, by create().
764
1499
  * Transparently follows pagination cursors and returns the full flat array.
765
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
766
1500
  * @param {string} userId - The user ID who owns the tokens
767
1501
  */
768
- list: (userId) =>
769
- listAll(this, `/api/v1/users/${userId}/tokens`),
1502
+ list: (userId) => listAll(this, `/api/v1/users/${userId}/tokens`),
770
1503
  /**
771
1504
  * Fetch a single page of a user's named API tokens.
772
1505
  * @param {string} userId - The user ID who owns the tokens
@@ -783,7 +1516,6 @@ class PlaidClient {
783
1516
  * @param {string} userId - The user ID who owns the tokens
784
1517
  * @param {object} [opts]
785
1518
  * @param {number} [opts.pageSize] - Per-request page size
786
- * 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.
787
1519
  * @returns {AsyncGenerator<Array>}
788
1520
  */
789
1521
  iterPages: (userId, { pageSize } = {}) =>
@@ -792,21 +1524,31 @@ class PlaidClient {
792
1524
  * Mint a named API token for a user. The returned `token` is the signed
793
1525
  * credential and is shown ONLY here — store it immediately. API tokens
794
1526
  * do not expire and survive password changes / logout; revoke to kill.
1527
+ * A client signed in with a named API token cannot do this (403): it
1528
+ * needs a sign-in token.
795
1529
  * @param {string} userId - The user ID who will own the token
796
- * @param {string} name - A human label, e.g. "Stanza Parser"
1530
+ * @param {string} name - A human label, e.g. "Stanza parser"
797
1531
  * @returns {Promise<{id: string, name: string, token: string}>}
798
1532
  */
799
1533
  create: (userId, name, auditMessage) =>
800
- this._request('POST', `/api/v1/users/${userId}/tokens`, { auditMessage,
1534
+ this._request("POST", `/api/v1/users/${userId}/tokens`, {
1535
+ auditMessage,
801
1536
  body: bodyOf({ name }),
1537
+ // The answer is the secret, which the server never keeps, so it
1538
+ // takes no Idempotency-Key and a batch cannot carry it.
1539
+ noBatch: true,
802
1540
  }),
803
1541
  /**
804
1542
  * Revoke a named API token (soft-revoke; idempotent).
1543
+ * A client signed in with a named API token cannot do this (403): it
1544
+ * needs a sign-in token.
805
1545
  * @param {string} userId - The user ID who owns the token
806
1546
  * @param {string} tokenId - The token ID to revoke
807
1547
  */
808
1548
  revoke: (userId, tokenId, auditMessage) =>
809
- this._request('DELETE', `/api/v1/users/${userId}/tokens/${tokenId}`, { auditMessage }),
1549
+ this._request("DELETE", `/api/v1/users/${userId}/tokens/${tokenId}`, {
1550
+ auditMessage,
1551
+ }),
810
1552
  };
811
1553
 
812
1554
  this.invites = {
@@ -816,32 +1558,42 @@ class PlaidClient {
816
1558
  * maintainer or admin on it. Never includes invite codes — the code is
817
1559
  * returned once, by create(), and is not recoverable afterward.
818
1560
  * Transparently follows pagination cursors and returns the full flat array.
819
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
820
1561
  * @param {object} [opts]
821
1562
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1563
+ * @param {boolean} [opts.all] - List every invite on the server (admin only)
822
1564
  */
823
- list: ({ projectId } = {}) =>
824
- listAll(this, '/api/v1/invites', { query: { 'project-id': projectId } }),
1565
+ list: ({ projectId, all } = {}) =>
1566
+ listAll(this, "/api/v1/invites", {
1567
+ query: { "project-id": projectId, all },
1568
+ }),
825
1569
  /**
826
1570
  * Fetch a single page of invites.
827
1571
  * @param {object} [opts]
828
1572
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1573
+ * @param {boolean} [opts.all] - List every invite on the server (admin only)
829
1574
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
830
1575
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
831
1576
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
832
1577
  */
833
- listPage: ({ projectId, limit, cursor } = {}) =>
834
- listPage(this, '/api/v1/invites', { limit, cursor, query: { 'project-id': projectId } }),
1578
+ listPage: ({ projectId, all, limit, cursor } = {}) =>
1579
+ listPage(this, "/api/v1/invites", {
1580
+ limit,
1581
+ cursor,
1582
+ query: { "project-id": projectId, all },
1583
+ }),
835
1584
  /**
836
1585
  * Async-iterate invites page by page; yields each page's entries array.
837
1586
  * @param {object} [opts]
838
1587
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1588
+ * @param {boolean} [opts.all] - List every invite on the server (admin only)
839
1589
  * @param {number} [opts.pageSize] - Per-request page size
840
- * 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.
841
1590
  * @returns {AsyncGenerator<Array>}
842
1591
  */
843
- iterPages: ({ projectId, pageSize } = {}) =>
844
- iterPages(this, '/api/v1/invites', { pageSize, query: { 'project-id': projectId } }),
1592
+ iterPages: ({ projectId, all, pageSize } = {}) =>
1593
+ iterPages(this, "/api/v1/invites", {
1594
+ pageSize,
1595
+ query: { "project-id": projectId, all },
1596
+ }),
845
1597
  /**
846
1598
  * Mint an invite. The returned `code` is shown ONLY here — it is never
847
1599
  * stored and cannot be recovered, so build and hand off the link now.
@@ -849,23 +1601,52 @@ class PlaidClient {
849
1601
  *
850
1602
  * Admins may mint anything. A project maintainer may mint role grants on
851
1603
  * projects they maintain, and nothing else: no admin grant, no grantless
852
- * invite, no password resets.
853
- * @param {object} opts
854
- * @param {string} [opts.projectId] - Project the redeemer joins (with projectRole)
855
- * @param {string} [opts.projectRole] - "reader" | "writer" | "maintainer"
1604
+ * invite, no password resets — so for a non-admin, projectId/projectRole
1605
+ * are required in practice (403 without them).
1606
+ *
1607
+ * Needs a signed-in session: a client signed in with a named API token
1608
+ * cannot mint any invite (403).
1609
+ *
1610
+ * EVERY option is optional; `create()` with no arguments mints a
1611
+ * single-use signup link granting nothing but an account. Two pairing
1612
+ * rules the server enforces with a 400: projectId and projectRole must be
1613
+ * given TOGETHER, and targetUserId may not be combined with projectId,
1614
+ * grantAdmin, or a maxUses above 1.
1615
+ * @param {object} [opts]
1616
+ * @param {string} [opts.projectId] - Project the redeemer joins (requires projectRole)
1617
+ * @param {string} [opts.projectRole] - "reader" | "writer" | "maintainer" (requires projectId)
856
1618
  * @param {boolean} [opts.grantAdmin] - Make the new account a global admin (admin only)
857
- * @param {string} [opts.targetUserId] - Make this a password reset for that user (admin only)
1619
+ * @param {string} [opts.targetUserId] - Password reset for that user instead of a signup; admin only, single-use, grants nothing
858
1620
  * @param {number} [opts.maxUses] - How many accounts this link may create (default 1)
859
1621
  * @param {number} [opts.ttlDays] - Days until it expires (default 14, max 365)
860
1622
  * @param {string} [opts.note] - Human label shown in your invite list
861
1623
  * @returns {Promise<{id: string, code: string, kind: string, status: string}>}
862
1624
  */
863
- create: ({ projectId, projectRole, grantAdmin, targetUserId, maxUses, ttlDays, note } = {}, auditMessage) =>
864
- this._request('POST', '/api/v1/invites', { auditMessage,
1625
+ create: (
1626
+ {
1627
+ projectId,
1628
+ projectRole,
1629
+ grantAdmin,
1630
+ targetUserId,
1631
+ maxUses,
1632
+ ttlDays,
1633
+ note,
1634
+ } = {},
1635
+ auditMessage,
1636
+ ) =>
1637
+ this._request("POST", "/api/v1/invites", {
1638
+ auditMessage,
1639
+ // The answer is the code, which the server never keeps, so it
1640
+ // takes no Idempotency-Key and a batch cannot carry it.
1641
+ noBatch: true,
865
1642
  body: bodyOf({
866
- 'project-id': projectId, 'project-role': projectRole,
867
- 'grant-admin': grantAdmin, 'target-user-id': targetUserId,
868
- 'max-uses': maxUses, 'ttl-days': ttlDays, note,
1643
+ "project-id": projectId,
1644
+ "project-role": projectRole,
1645
+ "grant-admin": grantAdmin,
1646
+ "target-user-id": targetUserId,
1647
+ "max-uses": maxUses,
1648
+ "ttl-days": ttlDays,
1649
+ note,
869
1650
  }),
870
1651
  }),
871
1652
  /**
@@ -874,129 +1655,491 @@ class PlaidClient {
874
1655
  * @param {string} id - The invite ID
875
1656
  */
876
1657
  revoke: (id, auditMessage) =>
877
- this._request('DELETE', `/api/v1/invites/${id}`, { auditMessage }),
1658
+ this._request("DELETE", `/api/v1/invites/${id}`, { auditMessage }),
878
1659
  };
879
1660
 
880
- this.tokenLayers = {
1661
+ this.admin = {
881
1662
  /**
882
- * Shift a token layer's display order.
883
- * @param {string} tokenLayerId - The token layer ID
884
- * @param {string} direction - The direction ("up" or "down")
1663
+ * Everything about the server in one read: version and JVM uptime,
1664
+ * database size and per-table row counts, media directory usage, backup
1665
+ * configuration and the backups on disk, and the settings an operator
1666
+ * gets asked to confirm. Carries no secrets. Runs a count per table and
1667
+ * walks the media directory, so open it, do not poll it. Admin only.
1668
+ * @returns {Promise<{version: string, jvm: object, database: object, media: object, backup: object, settings: object}>}
885
1669
  */
886
- shift: (tokenLayerId, direction, auditMessage) =>
887
- this._request('POST', `/api/v1/token-layers/${tokenLayerId}/shift`, { auditMessage,
888
- body: bodyOf({ direction }),
889
- }),
1670
+ server: () => this._request("GET", "/api/v1/admin/server"),
890
1671
  /**
891
- * Create a new token layer.
892
- * @param {string} textLayerId - The text layer ID
893
- * @param {string} name - The name
894
- * @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.
895
- * @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).
1672
+ * Take a database backup right now, outside the nightly schedule.
1673
+ * Resolves to the backup block with `ok` reporting whether the snapshot
1674
+ * succeeded. Uses VACUUM INTO, which only reads, so it is safe while
1675
+ * people are working. Admin only.
1676
+ *
1677
+ * The server answers once the backup is written, which takes minutes on
1678
+ * a large database, so this waits up to `BACKUP_TIMEOUT_MS` (30 minutes)
1679
+ * rather than the client's usual timeout. A proxy in front of the server
1680
+ * may still give up first (a 504): the backup goes on, and its file
1681
+ * shows in `server().backup.backups` once it is written.
1682
+ * @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
1683
+ *
1684
+ * outOfBand, as every admin action on the server itself is: none of them
1685
+ * writes project data (see the note at the top of http.js).
896
1686
  */
897
- create: (textLayerId, name, overlapMode, parentTokenLayerId, auditMessage) =>
898
- this._request('POST', '/api/v1/token-layers', { auditMessage,
899
- body: bodyOf({ 'text-layer-id': textLayerId, name, 'overlap-mode': overlapMode, 'parent-token-layer-id': parentTokenLayerId }),
1687
+ backup: () =>
1688
+ this._request("POST", "/api/v1/admin/backup", {
1689
+ outOfBand: true,
1690
+ timeout: BACKUP_TIMEOUT_MS,
900
1691
  }),
901
1692
  /**
902
- * Set a configuration value for a layer in an editor namespace.
903
- * @param {string} tokenLayerId - The token layer ID
904
- * @param {string} namespace - The config namespace
905
- * @param {string} configKey - The config key
906
- * @param {any} configValue - Configuration value to set
1693
+ * Documents currently held by an editing lock, with who holds each and
1694
+ * when it expires on its own. Admin only.
1695
+ * @returns {Promise<{entries: Array<{documentId: string, userId: string, expiresAt: number}>}>}
907
1696
  */
908
- setConfig: (tokenLayerId, namespace, configKey, configValue, auditMessage) =>
909
- this._request('PUT', `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`, { auditMessage,
910
- rawBody: configValue, skipResponseTransform: true,
911
- }),
1697
+ locks: () => this._request("GET", "/api/v1/admin/locks"),
912
1698
  /**
913
- * Remove a configuration value for a layer.
914
- * @param {string} tokenLayerId - The token layer ID
915
- * @param {string} namespace - The config namespace
916
- * @param {string} configKey - The config key
1699
+ * Drop the lock on a document whoever holds it. Idempotent. For a client
1700
+ * that went away without releasing one. Admin only.
1701
+ * @param {string} documentId - The document ID
917
1702
  */
918
- deleteConfig: (tokenLayerId, namespace, configKey, auditMessage) =>
919
- this._request('DELETE', `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`, { auditMessage,
920
- skipResponseTransform: true,
1703
+ releaseLock: (documentId) =>
1704
+ this._request("DELETE", `/api/v1/admin/locks/${documentId}`, {
1705
+ outOfBand: true,
921
1706
  }),
922
1707
  /**
923
- * Get a token layer by ID.
924
- * @param {string} tokenLayerId - The token layer ID
925
- * @param {string} [asOf] - Temporal query timestamp
1708
+ * Live login and invite rate-limit buckets: the address, the account
1709
+ * where there is one, failures inside the window, the limit, and whether
1710
+ * it is currently blocking. Admin only.
1711
+ * @returns {Promise<{windowMs: number, logins: Array, ips: Array, invites: Array}>}
926
1712
  */
927
- get: (tokenLayerId, asOf) =>
928
- this._request('GET', `/api/v1/token-layers/${tokenLayerId}`, {
929
- queryParams: { 'as-of': asOf },
930
- }),
1713
+ rateLimits: () => this._request("GET", "/api/v1/admin/rate-limits"),
931
1714
  /**
932
- * Delete a token layer.
933
- * @param {string} tokenLayerId - The token layer ID
1715
+ * Forget recorded rate-limit failures. With `ip`, clears that address,
1716
+ * narrowed to one account with `userId`. With neither, clears every
1717
+ * bucket. Only ever unblocks. Admin only.
1718
+ * @param {object} [opts]
1719
+ * @param {string} [opts.ip] - Clear only this address
1720
+ * @param {string} [opts.userId] - Narrow to one account on that address
934
1721
  */
935
- delete: (tokenLayerId, auditMessage) =>
936
- this._request('DELETE', `/api/v1/token-layers/${tokenLayerId}`, { auditMessage }),
1722
+ clearRateLimits: ({ ip, userId } = {}) =>
1723
+ this._request("DELETE", "/api/v1/admin/rate-limits", {
1724
+ outOfBand: true,
1725
+ queryParams: { ip, "user-id": userId },
1726
+ }),
937
1727
  /**
938
- * Update a token layer's name.
939
- * @param {string} tokenLayerId - The token layer ID
940
- * @param {string} name - The name
941
- */
942
- update: (tokenLayerId, name, auditMessage) =>
943
- this._request('PATCH', `/api/v1/token-layers/${tokenLayerId}`, { auditMessage,
944
- body: bodyOf({ name }),
1728
+ * What the server has logged, structured and filtered, from an in-memory
1729
+ * buffer kept whether or not a log file is configured. `requests` is one
1730
+ * entry per HTTP request (method, path, status, duration, and who made
1731
+ * it), `events` is everything else, with a stack trace where there was
1732
+ * one. They are buffered separately so a burst of requests cannot evict
1733
+ * an error. Both come newest first, `matched` counts what passed the
1734
+ * filters, and request `stats` describe the filtered set. Covers Plaid's
1735
+ * own log stream since the last restart: for library messages and older
1736
+ * history, see `logFile`. Admin only.
1737
+ * @param {object} [opts]
1738
+ * @param {number} [opts.limit] - Max entries per kind (default 200, max 2000)
1739
+ * @param {string} [opts.q] - Substring of any field, case-insensitive
1740
+ * @param {string} [opts.level] - Minimum level for events, e.g. "warn"
1741
+ * @param {string} [opts.status] - "2xx".."5xx", an exact code, or "failures"
1742
+ * @param {string} [opts.user] - Only requests made by this account
1743
+ * @param {string} [opts.method] - Only requests with this HTTP method
1744
+ * @returns {Promise<{requests: object, events: object, file: (string|null)}>}
1745
+ */
1746
+ logs: ({ limit, q, level, status, user, method } = {}) =>
1747
+ this._request("GET", "/api/v1/admin/logs", {
1748
+ queryParams: { limit, q, level, status, user, method },
945
1749
  }),
946
- };
947
-
948
- this.documents = {
949
1750
  /**
950
- * Check the lock status of a document.
951
- * @param {string} documentId - The document ID
952
- * @param {string} [asOf] - Temporal query timestamp
1751
+ * The tail of the configured log file, as text lines. The only place
1752
+ * third-party library messages and anything from before the last restart
1753
+ * can be read. Resolves with an `error` string instead of lines when no
1754
+ * log file is configured or it does not exist yet. Admin only.
1755
+ * @param {object} [opts]
1756
+ * @param {number} [opts.lines] - How many lines (default 200, max 2000)
1757
+ * @returns {Promise<{file: (string|null), lines: string[], error?: string}>}
953
1758
  */
954
- checkLock: (documentId, asOf) =>
955
- this._request('GET', `/api/v1/documents/${documentId}/lock`, {
956
- queryParams: { 'as-of': asOf },
1759
+ logFile: ({ lines } = {}) =>
1760
+ this._request("GET", "/api/v1/admin/logs/file", {
1761
+ queryParams: { lines },
957
1762
  }),
958
1763
  /**
959
- * Acquire or refresh a document lock
960
- * @param {string} documentId - The document ID
1764
+ * Private user-data entries across every account, each carrying its
1765
+ * `userId`. `/users/:userId/data` has always been owner-or-admin; this
1766
+ * is the same reach across accounts at once. Transparently follows
1767
+ * pagination cursors and returns the full flat array. Admin only.
1768
+ *
1769
+ * Narrow with `prefix` (the literal head of a key) and/or `pattern`, a
1770
+ * GLOB over the whole key (`*` any run, `?` one character) — the way to
1771
+ * ask for a key convention identified by a segment in the middle, e.g.
1772
+ * `igt:assistant:*:meta:*`. Values come only with `includeValues`, and
1773
+ * are recased like any other body (see `userData.put`).
1774
+ * @param {object} [opts]
1775
+ * @param {string} [opts.prefix] - Only keys starting with this
1776
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1777
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1778
+ * @returns {Promise<Array<{userId: string, key: string, updatedAt: string, value?: *}>>}
961
1779
  */
962
- acquireLock: (documentId, auditMessage) =>
963
- this._request('POST', `/api/v1/documents/${documentId}/lock`, { auditMessage }),
1780
+ userData: ({ prefix, pattern, includeValues } = {}) =>
1781
+ listAll(this, "/api/v1/admin/user-data", {
1782
+ query: {
1783
+ prefix,
1784
+ pattern,
1785
+ "include-values": includeValues,
1786
+ },
1787
+ }),
964
1788
  /**
965
- * Release a document lock
1789
+ * One page of private user-data entries across accounts, ordered by
1790
+ * (user, key). Admin only.
1791
+ * @param {object} [opts]
1792
+ * @param {string} [opts.prefix] - Only keys starting with this
1793
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1794
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1795
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1796
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1797
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1798
+ */
1799
+ userDataPage: ({ prefix, pattern, includeValues, limit, cursor } = {}) =>
1800
+ listPage(this, "/api/v1/admin/user-data", {
1801
+ limit,
1802
+ cursor,
1803
+ query: {
1804
+ prefix,
1805
+ pattern,
1806
+ "include-values": includeValues,
1807
+ },
1808
+ }),
1809
+ };
1810
+
1811
+ this.audit = {
1812
+ /**
1813
+ * The audit log across every project, oldest first. Same fold, window
1814
+ * and op-type filter as the per-project read, with the entity scope
1815
+ * dropped. Admin only. Transparently follows pagination cursors and
1816
+ * returns the full flat array.
1817
+ * @param {object} [opts]
1818
+ * @param {string} [opts.startTime] - Only operations at or after this instant
1819
+ * @param {string} [opts.endTime] - Only operations at or before this instant
1820
+ * @param {string[]|string} [opts.opTypes] - Only these op types (e.g.
1821
+ * `['span-layer/create']`). An entry appears when one of its operations
1822
+ * matches, carrying only the ones that did.
1823
+ * @param {string[]|string} [opts.kinds] - Only the entries of operations
1824
+ * of these kinds (e.g. `['review']`), each whole
1825
+ */
1826
+ list: ({ startTime, endTime, opTypes, kinds } = {}) =>
1827
+ listAll(this, "/api/v1/audit", {
1828
+ query: {
1829
+ "start-time": startTime,
1830
+ "end-time": endTime,
1831
+ "op-types": opTypesParam(opTypes),
1832
+ kinds: kindsParam(kinds),
1833
+ },
1834
+ }),
1835
+ /**
1836
+ * Fetch a single page of the instance-wide audit log. Admin only.
1837
+ * @param {object} [opts]
1838
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first, which
1839
+ * is what a feed wants. A cursor belongs to the direction that made it.
1840
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1841
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1842
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1843
+ */
1844
+ listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor } = {}) =>
1845
+ listPage(this, "/api/v1/audit", {
1846
+ limit,
1847
+ cursor,
1848
+ query: {
1849
+ "start-time": startTime,
1850
+ "end-time": endTime,
1851
+ "op-types": opTypesParam(opTypes),
1852
+ kinds: kindsParam(kinds),
1853
+ order,
1854
+ },
1855
+ }),
1856
+ /**
1857
+ * Async-iterate the instance-wide audit log page by page. Admin only.
1858
+ * @returns {AsyncGenerator<Array>}
1859
+ */
1860
+ iterPages: ({ startTime, endTime, opTypes, kinds, pageSize } = {}) =>
1861
+ iterPages(this, "/api/v1/audit", {
1862
+ pageSize,
1863
+ query: {
1864
+ "start-time": startTime,
1865
+ "end-time": endTime,
1866
+ "op-types": opTypesParam(opTypes),
1867
+ kinds: kindsParam(kinds),
1868
+ },
1869
+ }),
1870
+ /**
1871
+ * Per-user activity counts. `changes` is the number of logical actions,
1872
+ * folded the way the audit feed folds them, so one "Confirm word
1873
+ * analysis" counts once however many rows it wrote; `operations` is the
1874
+ * unfolded row count. Only users who did something appear — subtract
1875
+ * from the roster you already hold to find the ones who did not.
1876
+ *
1877
+ * With `projectId`, scoped to that project and open to its maintainers.
1878
+ * Without one, instance-wide and admin only.
1879
+ * @param {object} [opts]
1880
+ * @param {string} [opts.projectId] - Scope to one project
1881
+ * @param {string} [opts.startTime] - Only count at or after this instant
1882
+ * @param {string} [opts.endTime] - Only count at or before this instant
1883
+ * @param {boolean} [opts.daily] - Also return `byDay`, a list of
1884
+ * `{date, changes}` per user, oldest first, at the cost of a second
1885
+ * grouped scan
1886
+ * @returns {Promise<Array<{user: object, operations: number, changes: number, documents: number, firstTs: string, lastTs: string, byDay?: object}>>}
1887
+ */
1888
+ tally: async ({ projectId, startTime, endTime, daily } = {}) => {
1889
+ const path = projectId
1890
+ ? `/api/v1/projects/${projectId}/audit/tally`
1891
+ : "/api/v1/audit/tally";
1892
+ const result = await this._request("GET", path, {
1893
+ queryParams: {
1894
+ "start-time": startTime,
1895
+ "end-time": endTime,
1896
+ daily,
1897
+ },
1898
+ });
1899
+ return result.entries;
1900
+ },
1901
+ };
1902
+
1903
+ this.tokenLayers = {
1904
+ /**
1905
+ * Shift a token layer's display order.
1906
+ * @param {string} tokenLayerId - The token layer ID
1907
+ * @param {string} direction - The direction ("up" or "down")
1908
+ */
1909
+ shift: (tokenLayerId, direction, auditMessage) =>
1910
+ this._request("POST", `/api/v1/token-layers/${tokenLayerId}/shift`, {
1911
+ auditMessage,
1912
+ body: bodyOf({ direction }),
1913
+ }),
1914
+ /**
1915
+ * Create a new token layer.
1916
+ * @param {string} textLayerId - The text layer ID
1917
+ * @param {string} name - The name
1918
+ * @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.
1919
+ * @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).
1920
+ */
1921
+ create: (
1922
+ textLayerId,
1923
+ name,
1924
+ overlapMode,
1925
+ parentTokenLayerId,
1926
+ auditMessage,
1927
+ { id } = {},
1928
+ ) =>
1929
+ this._request("POST", "/api/v1/token-layers", {
1930
+ auditMessage,
1931
+ body: bodyOf({
1932
+ id,
1933
+ "text-layer-id": textLayerId,
1934
+ name,
1935
+ "overlap-mode": overlapMode,
1936
+ "parent-token-layer-id": parentTokenLayerId,
1937
+ }),
1938
+ }),
1939
+ /**
1940
+ * Set a configuration value for a layer in an editor namespace.
1941
+ * @param {string} tokenLayerId - The token layer ID
1942
+ * @param {string} namespace - The config namespace
1943
+ * @param {string} configKey - The config key
1944
+ * @param {any} configValue - Configuration value to set
1945
+ * @param {string} [auditMessage] - Audit message for this write
1946
+ * @param {{expected?: any}} [options] - `expected`: the value read for this key (null or undefined when it
1947
+ * was absent). The write is then refused with a 409 when the stored value differs.
1948
+ */
1949
+ setConfig: (tokenLayerId, namespace, configKey, configValue, auditMessage, options) =>
1950
+ this._request(
1951
+ "PUT",
1952
+ `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`,
1953
+ configRequest(auditMessage, configValue, options),
1954
+ ),
1955
+ /**
1956
+ * Remove a configuration value for a layer.
1957
+ * @param {string} tokenLayerId - The token layer ID
1958
+ * @param {string} namespace - The config namespace
1959
+ * @param {string} configKey - The config key
1960
+ * @param {string} [auditMessage] - Audit message for this write
1961
+ * @param {{expected?: any}} [options] - `expected`, as on setConfig.
1962
+ */
1963
+ deleteConfig: (tokenLayerId, namespace, configKey, auditMessage, options) =>
1964
+ this._request(
1965
+ "DELETE",
1966
+ `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`,
1967
+ configRequest(auditMessage, undefined, options, true),
1968
+ ),
1969
+ ...constraintMethods(this, "token-layers"),
1970
+ /**
1971
+ * Get a token layer by ID.
1972
+ * @param {string} tokenLayerId - The token layer ID
1973
+ */
1974
+ get: (tokenLayerId) =>
1975
+ this._request("GET", `/api/v1/token-layers/${tokenLayerId}`),
1976
+ /**
1977
+ * Delete a token layer.
1978
+ * @param {string} tokenLayerId - The token layer ID
1979
+ */
1980
+ delete: (tokenLayerId, auditMessage) =>
1981
+ this._request("DELETE", `/api/v1/token-layers/${tokenLayerId}`, {
1982
+ auditMessage,
1983
+ }),
1984
+ /**
1985
+ * Update a token layer's name.
1986
+ * @param {string} tokenLayerId - The token layer ID
1987
+ * @param {string} name - The name
1988
+ */
1989
+ update: (tokenLayerId, name, auditMessage) =>
1990
+ this._request("PATCH", `/api/v1/token-layers/${tokenLayerId}`, {
1991
+ auditMessage,
1992
+ body: bodyOf({ name }),
1993
+ }),
1994
+ };
1995
+
1996
+ this.documents = {
1997
+ /**
1998
+ * Check the lock status of a document.
966
1999
  * @param {string} documentId - The document ID
967
2000
  */
968
- releaseLock: (documentId, auditMessage) =>
969
- this._request('DELETE', `/api/v1/documents/${documentId}/lock`, { auditMessage }),
2001
+ checkLock: (documentId) =>
2002
+ this._request("GET", `/api/v1/documents/${documentId}/lock`),
970
2003
  /**
971
- * Get media file for a document
2004
+ * Acquire a document lock as a new holder.
2005
+ *
2006
+ * The answer's `lockId` names the holder: `renewLock` and `releaseLock`
2007
+ * take it. While the lock is held, a second acquire is refused with
2008
+ * HTTP 423, whoever makes it, this user included.
2009
+ *
2010
+ * `newLockId` names the holder from the client's side (a fresh UUID). An
2011
+ * acquire whose answer never arrived can then be sent again, which
2012
+ * answers 200 while that holder has the lock, or released. Without it
2013
+ * the server mints the id, and a lost answer leaves a lock nobody can
2014
+ * release until it expires.
2015
+ *
2016
+ * outOfBand: the lock is a signal, not project data (see the note at
2017
+ * the top of http.js). Queued on a batch it would be taken only at
2018
+ * submit, after every write it was meant to guard, and until then answer
2019
+ * success to a caller that does not hold it and cannot see the 423
2020
+ * saying somebody else does.
972
2021
  * @param {string} documentId - The document ID
973
- * @param {string} [asOf] - Temporal query timestamp
2022
+ * @param {string} [auditMessage]
2023
+ * @param {string} [newLockId] - A holder id this client minted
2024
+ */
2025
+ acquireLock: (documentId, auditMessage, newLockId) =>
2026
+ this._request("POST", `/api/v1/documents/${documentId}/lock`, {
2027
+ ...(newLockId !== undefined && newLockId !== null
2028
+ ? { queryParams: { "new-lock-id": newLockId } }
2029
+ : {}),
2030
+ auditMessage,
2031
+ outOfBand: true,
2032
+ }),
2033
+ /**
2034
+ * Renew the lock `lockId` holds while it is live. HTTP 423 if another
2035
+ * holder has it, and also once it has expired or been dropped, even when
2036
+ * nobody holds the document now: a renewal never takes a free document.
2037
+ * outOfBand, for the same reason as acquireLock.
2038
+ * @param {string} documentId - The document ID
2039
+ * @param {string} lockId - The `lockId` acquireLock answered with
974
2040
  */
975
- getMedia: (documentId, asOf) =>
976
- this._request('GET', `/api/v1/documents/${documentId}/media`, {
977
- queryParams: { 'as-of': asOf },
978
- noBatch: true,
2041
+ renewLock: (documentId, lockId, auditMessage) =>
2042
+ this._request("POST", `/api/v1/documents/${documentId}/lock`, {
2043
+ queryParams: { "lock-id": lockId },
2044
+ auditMessage,
2045
+ outOfBand: true,
2046
+ }),
2047
+ /**
2048
+ * Release the lock `lockId` holds. Idempotent: a lock that holder no
2049
+ * longer has is left alone. outOfBand, for the same reason as
2050
+ * acquireLock: queued, the lock would be held until the batch submits,
2051
+ * and not released at all if it aborts.
2052
+ * @param {string} documentId - The document ID
2053
+ * @param {string} lockId - The `lockId` acquireLock answered with
2054
+ */
2055
+ releaseLock: (documentId, lockId, auditMessage) =>
2056
+ this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
2057
+ queryParams: { "lock-id": lockId },
2058
+ auditMessage,
2059
+ outOfBand: true,
2060
+ }),
2061
+ /**
2062
+ * Hold this document's server-enforced lock for the length of `fn`,
2063
+ * releasing it on the way out (including on error). Resolves to what
2064
+ * `fn` returned.
2065
+ *
2066
+ * await client.documents.locked(docId, async () => {
2067
+ * // delete + recreate tokens
2068
+ * });
2069
+ *
2070
+ * Wrap any multi-step, server-side mutation of a document that must not
2071
+ * interleave with a human editor or another service, e.g. a parser or
2072
+ * tokenizer that deletes and recreates a document's tokens, spans and
2073
+ * relations. A single atomic call does not need it. While the lock is
2074
+ * held, writes to the document by ANOTHER user are refused with HTTP
2075
+ * 423; the holder's own writes pass and renew it. If anyone already
2076
+ * holds it, another block of this same user included, this rejects with
2077
+ * a readable 423 and `fn` does not run.
2078
+ *
2079
+ * The lock is renewed for as long as `fn` runs, so work that computes
2080
+ * for minutes before it writes holds the lock the whole time rather than
2081
+ * only for its first minute. If a renewal fails the lock is gone: every
2082
+ * later write from this client throws `DocumentLockLost`, and a block
2083
+ * that got to the end anyway ends with that error rather than reporting
2084
+ * success. `fn` receives a lock handle and may read `lock.lost` (or call
2085
+ * `lock.raiseIfLost()`) to give up sooner.
2086
+ *
2087
+ * The lock is per HOLDER: each block is its own, named by
2088
+ * `lock.lockId`, and only that id renews or releases it. NOT
2089
+ * re-entrant: a nested `locked()` block on one document is a second
2090
+ * holder and gets the 423. Lock at exactly one level per call path.
2091
+ * The block mints its holder id and sends it with the acquire, so an
2092
+ * acquire whose answer was lost is sent again (up to three tries) and,
2093
+ * if none is answered, released rather than left to expire. A
2094
+ * lost lock is recorded on the CLIENT, like strict mode, so it stops
2095
+ * every write the client makes (on any batch of it too) and not only the
2096
+ * ones this block makes.
2097
+ * @param {string} documentId - The document ID
2098
+ * @param {(lock: DocumentLock) => any} fn - The work to run while holding it
2099
+ * @param {object} [options] - `{ keepAlive }`: renew on a timer (default true)
2100
+ */
2101
+ locked: (documentId, fn, options) =>
2102
+ withDocumentLock(this, documentId, fn, options),
2103
+ /**
2104
+ * Get the media file for a document. Media is not versioned, so there is
2105
+ * no as-of form: the route refuses the parameter. Prefer the document's
2106
+ * own `mediaUrl`, which carries the file's version for caching.
2107
+ * @param {string} documentId - The document ID
2108
+ */
2109
+ getMedia: (documentId) =>
2110
+ this._request("GET", `/api/v1/documents/${documentId}/media`, {
979
2111
  binaryResponse: true,
980
2112
  }),
981
2113
  /**
982
2114
  * Upload a media file for a document. Uses Apache Tika for content validation.
983
2115
  * @param {string} documentId - The document ID
984
2116
  * @param {File} file - The file to upload
2117
+ * @param {string} [auditMessage] - Custom audit-log message for this write
2118
+ * @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.
985
2119
  */
986
- uploadMedia: (documentId, file, auditMessage) => {
2120
+ uploadMedia: (documentId, file, auditMessage, { onProgress } = {}) => {
987
2121
  const fd = new FormData();
988
- fd.append('file', file);
989
- return this._request('PUT', `/api/v1/documents/${documentId}/media`, { auditMessage,
990
- body: fd, formData: true, noBatch: true,
2122
+ fd.append("file", file);
2123
+ return this._request("PUT", `/api/v1/documents/${documentId}/media`, {
2124
+ auditMessage,
2125
+ body: fd,
2126
+ formData: true,
2127
+ noBatch: true,
2128
+ onUploadProgress: onProgress,
991
2129
  });
992
2130
  },
993
2131
  /**
994
2132
  * Delete media file for a document
995
2133
  * @param {string} documentId - The document ID
996
2134
  */
2135
+ // The upload above is multipart and cannot be batched, but a DELETE
2136
+ // carries no blob, so the batch transport takes it. It is a write
2137
+ // of the document's own data and queues like any other. Note that the
2138
+ // file removal happens outside the server's transaction, so a batch that
2139
+ // aborts after this op does not bring the file back.
997
2140
  deleteMedia: (documentId, auditMessage) =>
998
- this._request('DELETE', `/api/v1/documents/${documentId}/media`, { auditMessage,
999
- noBatch: true,
2141
+ this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
2142
+ auditMessage,
1000
2143
  }),
1001
2144
  /**
1002
2145
  * Replace all metadata for a document.
@@ -1004,48 +2147,98 @@ class PlaidClient {
1004
2147
  * @param {any} body - The request body
1005
2148
  */
1006
2149
  setMetadata: (documentId, body, auditMessage) =>
1007
- this._request('PUT', `/api/v1/documents/${documentId}/metadata`, { auditMessage,
1008
- rawBody: body, skipResponseTransform: true,
2150
+ this._request("PUT", `/api/v1/documents/${documentId}/metadata`, {
2151
+ auditMessage,
2152
+ rawBody: body,
1009
2153
  }),
1010
2154
  /**
1011
2155
  * Remove all metadata from a document.
1012
2156
  * @param {string} documentId - The document ID
1013
2157
  */
1014
2158
  deleteMetadata: (documentId, auditMessage) =>
1015
- this._request('DELETE', `/api/v1/documents/${documentId}/metadata`, { auditMessage,
1016
- skipResponseTransform: true,
2159
+ this._request("DELETE", `/api/v1/documents/${documentId}/metadata`, {
2160
+ auditMessage,
1017
2161
  }),
1018
2162
  /**
1019
- * Patch (shallow-merge) metadata for a document. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
2163
+ * Edit metadata for a document with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
1020
2164
  * @param {string} documentId - The document ID
1021
- * @param {any} body - The metadata patch
2165
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
1022
2166
  */
1023
2167
  patchMetadata: (documentId, body, auditMessage) =>
1024
- this._request('PATCH', `/api/v1/documents/${documentId}/metadata`, { auditMessage,
1025
- rawBody: body, skipResponseTransform: true,
2168
+ this._request("PATCH", `/api/v1/documents/${documentId}/metadata`, {
2169
+ auditMessage,
2170
+ rawBody: body,
1026
2171
  }),
1027
2172
  /**
1028
2173
  * Get audit log for a document. Transparently follows pagination cursors
1029
2174
  * and returns the full flat array.
1030
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1031
2175
  * @param {string} documentId - The document ID
1032
2176
  * @param {string} [startTime] - Start of time range
1033
2177
  * @param {string} [endTime] - End of time range
1034
- * @param {string} [asOf] - Temporal query timestamp
1035
2178
  * @param {string[]|string} [opTypes] - Only return operations of these
1036
2179
  * types, spelled as in an entry's `op/type` (e.g.
1037
2180
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1038
2181
  * one of its operations matches, carrying only the ones that did.
2182
+ * @param {string[]|string} [kinds] - Only the entries of operations of
2183
+ * these kinds (e.g. `['review', 'guess-adoption']`), each whole
1039
2184
  */
1040
- audit: (documentId, startTime, endTime, asOf, opTypes) =>
2185
+ audit: (documentId, startTime, endTime, opTypes, kinds) =>
1041
2186
  listAll(this, `/api/v1/documents/${documentId}/audit`, {
1042
2187
  query: {
1043
- 'start-time': startTime,
1044
- 'end-time': endTime,
1045
- 'as-of': asOf,
1046
- 'op-types': opTypesParam(opTypes),
2188
+ "start-time": startTime,
2189
+ "end-time": endTime,
2190
+ "op-types": opTypesParam(opTypes),
2191
+ kinds: kindsParam(kinds),
2192
+ },
2193
+ }),
2194
+ /**
2195
+ * One page of the same log, newest-first with `order: "desc"`. Use this
2196
+ * rather than audit() wherever the caller wants the recent end of a log
2197
+ * that may be long: audit() walks every page before it resolves.
2198
+ * @param {string} documentId - The document ID
2199
+ * @param {object} [opts]
2200
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2201
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2202
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
2203
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2204
+ */
2205
+ auditPage: (
2206
+ documentId,
2207
+ { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2208
+ ) =>
2209
+ listPage(this, `/api/v1/documents/${documentId}/audit`, {
2210
+ limit,
2211
+ cursor,
2212
+ query: {
2213
+ "start-time": startTime,
2214
+ "end-time": endTime,
2215
+ "op-types": opTypesParam(opTypes),
2216
+ kinds: kindsParam(kinds),
2217
+ order,
1047
2218
  },
1048
2219
  }),
2220
+ /**
2221
+ * Restore a document to its state at an earlier time, as one
2222
+ * operation: what was deleted since then comes back under its original
2223
+ * id, what was added since is removed, and what changed is set back,
2224
+ * across every layer. A layer deleted since then, or a vocabulary entry
2225
+ * that no longer exists, is skipped and reported under `skipped`.
2226
+ * Resolves to a summary of the changes. Maintainers only.
2227
+ * @param {string} documentId - The document ID
2228
+ * @param {string} asOf - The moment to go back to (ISO-8601 instant),
2229
+ * typically a history entry's `endTime`
2230
+ * @param {object} [options] - `{ dryRun }`: with `dryRun` true nothing
2231
+ * is written and the summary says what would change
2232
+ * @param {string} [auditMessage] - Custom audit message for this operation
2233
+ */
2234
+ restore: (documentId, asOf, { dryRun } = {}, auditMessage) =>
2235
+ this._request("POST", `/api/v1/documents/${documentId}/restore`, {
2236
+ queryParams: {
2237
+ "as-of": asOf,
2238
+ "dry-run": dryRun ? "true" : undefined,
2239
+ },
2240
+ auditMessage,
2241
+ }),
1049
2242
  /**
1050
2243
  * Get a document. Set `includeBody` to true to include all data.
1051
2244
  *
@@ -1062,10 +2255,10 @@ class PlaidClient {
1062
2255
  * @param {string[]|string} [layers] - Layer ids to restrict a body read to
1063
2256
  */
1064
2257
  get: (documentId, includeBody, asOf, layers) =>
1065
- this._request('GET', `/api/v1/documents/${documentId}`, {
2258
+ this._request("GET", `/api/v1/documents/${documentId}`, {
1066
2259
  queryParams: {
1067
- 'include-body': includeBody,
1068
- 'as-of': asOf,
2260
+ "include-body": includeBody,
2261
+ "as-of": asOf,
1069
2262
  layers: layersParam(layers),
1070
2263
  },
1071
2264
  }),
@@ -1074,14 +2267,17 @@ class PlaidClient {
1074
2267
  * @param {string} documentId - The document ID
1075
2268
  */
1076
2269
  delete: (documentId, auditMessage) =>
1077
- this._request('DELETE', `/api/v1/documents/${documentId}`, { auditMessage }),
2270
+ this._request("DELETE", `/api/v1/documents/${documentId}`, {
2271
+ auditMessage,
2272
+ }),
1078
2273
  /**
1079
2274
  * Update a document's name.
1080
2275
  * @param {string} documentId - The document ID
1081
2276
  * @param {string} name - The name
1082
2277
  */
1083
2278
  update: (documentId, name, auditMessage) =>
1084
- this._request('PATCH', `/api/v1/documents/${documentId}`, { auditMessage,
2279
+ this._request("PATCH", `/api/v1/documents/${documentId}`, {
2280
+ auditMessage,
1085
2281
  body: bodyOf({ name }),
1086
2282
  }),
1087
2283
  /**
@@ -1090,9 +2286,29 @@ class PlaidClient {
1090
2286
  * @param {string} name - The name
1091
2287
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
1092
2288
  */
1093
- create: (projectId, name, metadata, auditMessage) =>
1094
- this._request('POST', '/api/v1/documents', { auditMessage,
1095
- body: bodyOf({ 'project-id': projectId, name, metadata }),
2289
+ create: (projectId, name, metadata, auditMessage, { id } = {}) =>
2290
+ this._request("POST", "/api/v1/documents", {
2291
+ auditMessage,
2292
+ body: bodyOf({ id, "project-id": projectId, name, metadata }),
2293
+ }),
2294
+ /**
2295
+ * Copy a document and everything in it into a new document of the same
2296
+ * project, as one operation. The copy shares the source's layers and
2297
+ * the vocabulary entries its links name, and holds the source's texts,
2298
+ * tokens, spans, relations and vocab links under fresh ids, with their
2299
+ * metadata. Comments do not travel. The media file does, unless
2300
+ * `includeMedia` is false. Resolves to `{ id }`, plus `mediaError` when
2301
+ * the source had media the copy could not take with it.
2302
+ * @param {string} documentId - The document to copy
2303
+ * @param {string} name - The new document's name
2304
+ * @param {object} [options] - `{ includeMedia }`: false leaves the media
2305
+ * file behind
2306
+ * @param {string} [auditMessage] - Custom audit message for this operation
2307
+ */
2308
+ copy: (documentId, name, { includeMedia, id } = {}, auditMessage) =>
2309
+ this._request("POST", `/api/v1/documents/${documentId}/copy`, {
2310
+ auditMessage,
2311
+ body: bodyOf({ id, name, "include-media": includeMedia }),
1096
2312
  }),
1097
2313
  };
1098
2314
 
@@ -1103,120 +2319,182 @@ class PlaidClient {
1103
2319
  * @param {string} userId - The user ID
1104
2320
  */
1105
2321
  addWriter: (id, userId, auditMessage) =>
1106
- this._request('POST', `/api/v1/projects/${id}/writers/${userId}`, { auditMessage }),
2322
+ this._request("POST", `/api/v1/projects/${id}/writers/${userId}`, {
2323
+ auditMessage,
2324
+ }),
1107
2325
  /**
1108
2326
  * Remove a user's writer privileges for this project.
1109
2327
  * @param {string} id - The resource ID
1110
2328
  * @param {string} userId - The user ID
1111
2329
  */
1112
2330
  removeWriter: (id, userId, auditMessage) =>
1113
- this._request('DELETE', `/api/v1/projects/${id}/writers/${userId}`, { auditMessage }),
2331
+ this._request("DELETE", `/api/v1/projects/${id}/writers/${userId}`, {
2332
+ auditMessage,
2333
+ }),
1114
2334
  /**
1115
2335
  * Set a user's access level to read-only for this project.
1116
2336
  * @param {string} id - The resource ID
1117
2337
  * @param {string} userId - The user ID
1118
2338
  */
1119
2339
  addReader: (id, userId, auditMessage) =>
1120
- this._request('POST', `/api/v1/projects/${id}/readers/${userId}`, { auditMessage }),
2340
+ this._request("POST", `/api/v1/projects/${id}/readers/${userId}`, {
2341
+ auditMessage,
2342
+ }),
1121
2343
  /**
1122
2344
  * Remove a user's reader privileges for this project.
1123
2345
  * @param {string} id - The resource ID
1124
2346
  * @param {string} userId - The user ID
1125
2347
  */
1126
2348
  removeReader: (id, userId, auditMessage) =>
1127
- this._request('DELETE', `/api/v1/projects/${id}/readers/${userId}`, { auditMessage }),
2349
+ this._request("DELETE", `/api/v1/projects/${id}/readers/${userId}`, {
2350
+ auditMessage,
2351
+ }),
1128
2352
  /**
1129
2353
  * Set a configuration value for a project in an editor namespace.
1130
2354
  * @param {string} id - The resource ID
1131
2355
  * @param {string} namespace - The config namespace
1132
2356
  * @param {string} configKey - The config key
1133
2357
  * @param {any} configValue - Configuration value to set
2358
+ * @param {string} [auditMessage] - Audit message for this write
2359
+ * @param {{expected?: any}} [options] - `expected`: the value read for this key (null or undefined when it
2360
+ * was absent). The write is then refused with a 409 when the stored value differs.
1134
2361
  */
1135
- setConfig: (id, namespace, configKey, configValue, auditMessage) =>
1136
- this._request('PUT', `/api/v1/projects/${id}/config/${namespace}/${configKey}`, { auditMessage,
1137
- rawBody: configValue, skipResponseTransform: true,
1138
- }),
2362
+ setConfig: (id, namespace, configKey, configValue, auditMessage, options) =>
2363
+ this._request(
2364
+ "PUT",
2365
+ `/api/v1/projects/${id}/config/${namespace}/${configKey}`,
2366
+ configRequest(auditMessage, configValue, options),
2367
+ ),
1139
2368
  /**
1140
2369
  * Remove a configuration value for a project.
1141
2370
  * @param {string} id - The resource ID
1142
2371
  * @param {string} namespace - The config namespace
1143
2372
  * @param {string} configKey - The config key
2373
+ * @param {string} [auditMessage] - Audit message for this write
2374
+ * @param {{expected?: any}} [options] - `expected`, as on setConfig.
1144
2375
  */
1145
- deleteConfig: (id, namespace, configKey, auditMessage) =>
1146
- this._request('DELETE', `/api/v1/projects/${id}/config/${namespace}/${configKey}`, { auditMessage,
1147
- skipResponseTransform: true,
1148
- }),
2376
+ deleteConfig: (id, namespace, configKey, auditMessage, options) =>
2377
+ this._request(
2378
+ "DELETE",
2379
+ `/api/v1/projects/${id}/config/${namespace}/${configKey}`,
2380
+ configRequest(auditMessage, undefined, options, true),
2381
+ ),
1149
2382
  /**
1150
2383
  * Assign a user as a maintainer for this project.
1151
2384
  * @param {string} id - The resource ID
1152
2385
  * @param {string} userId - The user ID
1153
2386
  */
1154
2387
  addMaintainer: (id, userId, auditMessage) =>
1155
- this._request('POST', `/api/v1/projects/${id}/maintainers/${userId}`, { auditMessage }),
2388
+ this._request("POST", `/api/v1/projects/${id}/maintainers/${userId}`, {
2389
+ auditMessage,
2390
+ }),
1156
2391
  /**
1157
2392
  * Remove a user's maintainer privileges for this project.
1158
2393
  * @param {string} id - The resource ID
1159
2394
  * @param {string} userId - The user ID
1160
2395
  */
1161
2396
  removeMaintainer: (id, userId, auditMessage) =>
1162
- this._request('DELETE', `/api/v1/projects/${id}/maintainers/${userId}`, { auditMessage }),
2397
+ this._request(
2398
+ "DELETE",
2399
+ `/api/v1/projects/${id}/maintainers/${userId}`,
2400
+ { auditMessage },
2401
+ ),
1163
2402
  /**
1164
2403
  * Get audit log for a project. Transparently follows pagination cursors
1165
2404
  * and returns the full flat array.
1166
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1167
2405
  * @param {string} projectId - The project ID
1168
2406
  * @param {string} [startTime] - Start of time range
1169
2407
  * @param {string} [endTime] - End of time range
1170
- * @param {string} [asOf] - Temporal query timestamp
1171
2408
  * @param {string[]|string} [opTypes] - Only return operations of these
1172
2409
  * types, spelled as in an entry's `op/type` (e.g.
1173
2410
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1174
2411
  * one of its operations matches, carrying only the ones that did.
2412
+ * @param {string[]|string} [kinds] - Only the entries of operations of
2413
+ * these kinds (e.g. `['review', 'guess-adoption']`), each whole
1175
2414
  */
1176
- audit: (projectId, startTime, endTime, asOf, opTypes) =>
2415
+ audit: (projectId, startTime, endTime, opTypes, kinds) =>
1177
2416
  listAll(this, `/api/v1/projects/${projectId}/audit`, {
1178
2417
  query: {
1179
- 'start-time': startTime,
1180
- 'end-time': endTime,
1181
- 'as-of': asOf,
1182
- 'op-types': opTypesParam(opTypes),
2418
+ "start-time": startTime,
2419
+ "end-time": endTime,
2420
+ "op-types": opTypesParam(opTypes),
2421
+ kinds: kindsParam(kinds),
1183
2422
  },
1184
2423
  }),
2424
+ /**
2425
+ * One page of the same log, newest-first with `order: "desc"`. Use this
2426
+ * rather than audit() wherever the caller wants the recent end of a log
2427
+ * that may be long: audit() walks every page before it resolves.
2428
+ * @param {string} projectId - The project ID
2429
+ * @param {object} [opts]
2430
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2431
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2432
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
2433
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2434
+ */
2435
+ auditPage: (
2436
+ projectId,
2437
+ { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2438
+ ) =>
2439
+ listPage(this, `/api/v1/projects/${projectId}/audit`, {
2440
+ limit,
2441
+ cursor,
2442
+ query: {
2443
+ "start-time": startTime,
2444
+ "end-time": endTime,
2445
+ "op-types": opTypesParam(opTypes),
2446
+ kinds: kindsParam(kinds),
2447
+ order,
2448
+ },
2449
+ }),
2450
+ /**
2451
+ * When the calling user last wrote to each document in a project, as a
2452
+ * `{documentId: timestamp}` map. Documents they have never written to
2453
+ * are absent. One request covers a whole document list.
2454
+ *
2455
+ * The response is NOT key-transformed: its keys are document ids, and
2456
+ * camelCasing would mangle the hyphens in a UUID.
2457
+ * @param {string} projectId - The project ID
2458
+ * @returns {Promise<Object<string, string>>}
2459
+ */
2460
+ myLastEdits: (projectId) =>
2461
+ this._request("GET", `/api/v1/projects/${projectId}/audit/last-edits`, {
2462
+ skipResponseTransform: true,
2463
+ }),
1185
2464
  /**
1186
2465
  * Link a vocabulary to a project.
1187
2466
  * @param {string} id - The resource ID
1188
2467
  * @param {string} vocabId - The vocab layer ID
1189
2468
  */
1190
2469
  linkVocab: (id, vocabId, auditMessage) =>
1191
- this._request('POST', `/api/v1/projects/${id}/vocabs/${vocabId}`, { auditMessage }),
2470
+ this._request("POST", `/api/v1/projects/${id}/vocabs/${vocabId}`, {
2471
+ auditMessage,
2472
+ }),
1192
2473
  /**
1193
2474
  * Unlink a vocabulary from a project.
1194
2475
  * @param {string} id - The resource ID
1195
2476
  * @param {string} vocabId - The vocab layer ID
1196
2477
  */
1197
2478
  unlinkVocab: (id, vocabId, auditMessage) =>
1198
- this._request('DELETE', `/api/v1/projects/${id}/vocabs/${vocabId}`, { auditMessage }),
2479
+ this._request("DELETE", `/api/v1/projects/${id}/vocabs/${vocabId}`, {
2480
+ auditMessage,
2481
+ }),
1199
2482
  /**
1200
2483
  * Get a project by ID. To fetch the project's documents, use
1201
2484
  * listDocuments(id) — the include-documents flag has been removed.
1202
2485
  * @param {string} id - The resource ID
1203
- * @param {string} [asOf] - Temporal query timestamp
1204
2486
  */
1205
- get: (id, asOf) =>
1206
- this._request('GET', `/api/v1/projects/${id}`, {
1207
- queryParams: { 'as-of': asOf },
1208
- }),
2487
+ get: (id) =>
2488
+ this._request("GET", `/api/v1/projects/${id}`),
1209
2489
  /**
1210
2490
  * List all documents in a project. Transparently follows pagination
1211
2491
  * cursors and returns the full flat array.
1212
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1213
2492
  *
1214
2493
  * Note: this endpoint does not support temporal (`as-of`) queries; the
1215
2494
  * server rejects `?as-of=` on the documents-list route with a 400.
1216
2495
  * @param {string} id - The project ID
1217
2496
  */
1218
- listDocuments: (id) =>
1219
- listAll(this, `/api/v1/projects/${id}/documents`),
2497
+ listDocuments: (id) => listAll(this, `/api/v1/projects/${id}/documents`),
1220
2498
  /**
1221
2499
  * Fetch a single page of a project's documents.
1222
2500
  *
@@ -1239,68 +2517,69 @@ class PlaidClient {
1239
2517
  * @param {string} id - The project ID
1240
2518
  * @param {object} [opts]
1241
2519
  * @param {number} [opts.pageSize] - Per-request page size
1242
- * 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.
1243
2520
  * @returns {AsyncGenerator<Array>}
1244
2521
  */
1245
2522
  iterDocuments: (id, { pageSize } = {}) =>
1246
2523
  iterPages(this, `/api/v1/projects/${id}/documents`, { pageSize }),
1247
2524
  /**
1248
- * Delete a project and everything in it. This is irrecoverable.
1249
- *
1250
- * For a large project this can take a long time, so the client's
1251
- * per-request timeout is DISABLED by default. Pass `{ timeout }` to
1252
- * impose a finite bound.
2525
+ * Delete a project and everything in it. This is irrecoverable. The
2526
+ * project is gone when this returns, and what it holds is removed on
2527
+ * the server afterwards.
1253
2528
  * @param {string} id - The resource ID
1254
2529
  * @param {string} [auditMessage] - Custom audit-log message
1255
2530
  * @param {object} [options]
1256
- * @param {number} [options.timeout=0] - Per-request timeout in ms (0/null disables)
2531
+ * @param {number} [options.timeout] - Per-request timeout in ms, the client's own by default (0/null disables)
1257
2532
  */
1258
- delete: (id, auditMessage, { timeout = 0 } = {}) =>
1259
- this._request('DELETE', `/api/v1/projects/${id}`, { auditMessage, timeout }),
2533
+ delete: (id, auditMessage, { timeout } = {}) =>
2534
+ this._request("DELETE", `/api/v1/projects/${id}`, {
2535
+ auditMessage,
2536
+ timeout,
2537
+ }),
1260
2538
  /**
1261
2539
  * Update a project's name.
1262
2540
  * @param {string} id - The resource ID
1263
2541
  * @param {string} name - The name
1264
2542
  */
1265
2543
  update: (id, name, auditMessage) =>
1266
- this._request('PATCH', `/api/v1/projects/${id}`, { auditMessage,
2544
+ this._request("PATCH", `/api/v1/projects/${id}`, {
2545
+ auditMessage,
1267
2546
  body: bodyOf({ name }),
1268
2547
  }),
1269
2548
  /**
1270
2549
  * List all projects accessible to user. Transparently follows pagination
1271
2550
  * cursors and returns the full flat array.
1272
- * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1273
- * @param {string} [asOf] - Temporal query timestamp
1274
2551
  */
1275
- list: (asOf) =>
1276
- listAll(this, '/api/v1/projects', { query: { 'as-of': asOf } }),
2552
+ list: () => listAll(this, "/api/v1/projects"),
1277
2553
  /**
1278
2554
  * Fetch a single page of projects.
1279
2555
  * @param {object} [opts]
1280
2556
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1281
2557
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1282
- * @param {string} [opts.asOf] - Temporal query timestamp
1283
2558
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1284
2559
  */
1285
- listPage: ({ limit, cursor, asOf } = {}) =>
1286
- listPage(this, '/api/v1/projects', { limit, cursor, query: { 'as-of': asOf } }),
2560
+ listPage: ({ limit, cursor } = {}) =>
2561
+ listPage(this, "/api/v1/projects", {
2562
+ limit,
2563
+ cursor,
2564
+ }),
1287
2565
  /**
1288
2566
  * Async-iterate projects page by page; yields each page's entries array.
1289
2567
  * @param {object} [opts]
1290
2568
  * @param {number} [opts.pageSize] - Per-request page size
1291
- * @param {string} [opts.asOf] - Temporal query timestamp
1292
- * 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.
1293
2569
  * @returns {AsyncGenerator<Array>}
1294
2570
  */
1295
- iterPages: ({ pageSize, asOf } = {}) =>
1296
- iterPages(this, '/api/v1/projects', { pageSize, query: { 'as-of': asOf } }),
2571
+ iterPages: ({ pageSize } = {}) =>
2572
+ iterPages(this, "/api/v1/projects", {
2573
+ pageSize,
2574
+ }),
1297
2575
  /**
1298
2576
  * Create a new project. Note: this also registers the user as a maintainer.
1299
2577
  * @param {string} name - The name
1300
2578
  */
1301
- create: (name, auditMessage) =>
1302
- this._request('POST', '/api/v1/projects', { auditMessage,
1303
- body: bodyOf({ name }),
2579
+ create: (name, auditMessage, { id } = {}) =>
2580
+ this._request("POST", "/api/v1/projects", {
2581
+ auditMessage,
2582
+ body: bodyOf({ id, name }),
1304
2583
  }),
1305
2584
  };
1306
2585
 
@@ -1311,43 +2590,52 @@ class PlaidClient {
1311
2590
  * @param {string} namespace - The config namespace
1312
2591
  * @param {string} configKey - The config key
1313
2592
  * @param {any} configValue - Configuration value to set
2593
+ * @param {string} [auditMessage] - Audit message for this write
2594
+ * @param {{expected?: any}} [options] - `expected`: the value read for this key (null or undefined when it
2595
+ * was absent). The write is then refused with a 409 when the stored value differs.
1314
2596
  */
1315
- setConfig: (textLayerId, namespace, configKey, configValue, auditMessage) =>
1316
- this._request('PUT', `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1317
- rawBody: configValue, skipResponseTransform: true,
1318
- }),
2597
+ setConfig: (textLayerId, namespace, configKey, configValue, auditMessage, options) =>
2598
+ this._request(
2599
+ "PUT",
2600
+ `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`,
2601
+ configRequest(auditMessage, configValue, options),
2602
+ ),
1319
2603
  /**
1320
2604
  * Remove a configuration value for a layer.
1321
2605
  * @param {string} textLayerId - The text layer ID
1322
2606
  * @param {string} namespace - The config namespace
1323
2607
  * @param {string} configKey - The config key
2608
+ * @param {string} [auditMessage] - Audit message for this write
2609
+ * @param {{expected?: any}} [options] - `expected`, as on setConfig.
1324
2610
  */
1325
- deleteConfig: (textLayerId, namespace, configKey, auditMessage) =>
1326
- this._request('DELETE', `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1327
- skipResponseTransform: true,
1328
- }),
2611
+ deleteConfig: (textLayerId, namespace, configKey, auditMessage, options) =>
2612
+ this._request(
2613
+ "DELETE",
2614
+ `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`,
2615
+ configRequest(auditMessage, undefined, options, true),
2616
+ ),
1329
2617
  /**
1330
2618
  * Get a text layer by ID.
1331
2619
  * @param {string} textLayerId - The text layer ID
1332
- * @param {string} [asOf] - Temporal query timestamp
1333
2620
  */
1334
- get: (textLayerId, asOf) =>
1335
- this._request('GET', `/api/v1/text-layers/${textLayerId}`, {
1336
- queryParams: { 'as-of': asOf },
1337
- }),
2621
+ get: (textLayerId) =>
2622
+ this._request("GET", `/api/v1/text-layers/${textLayerId}`),
1338
2623
  /**
1339
2624
  * Delete a text layer.
1340
2625
  * @param {string} textLayerId - The text layer ID
1341
2626
  */
1342
2627
  delete: (textLayerId, auditMessage) =>
1343
- this._request('DELETE', `/api/v1/text-layers/${textLayerId}`, { auditMessage }),
2628
+ this._request("DELETE", `/api/v1/text-layers/${textLayerId}`, {
2629
+ auditMessage,
2630
+ }),
1344
2631
  /**
1345
2632
  * Update a text layer's name.
1346
2633
  * @param {string} textLayerId - The text layer ID
1347
2634
  * @param {string} name - The name
1348
2635
  */
1349
2636
  update: (textLayerId, name, auditMessage) =>
1350
- this._request('PATCH', `/api/v1/text-layers/${textLayerId}`, { auditMessage,
2637
+ this._request("PATCH", `/api/v1/text-layers/${textLayerId}`, {
2638
+ auditMessage,
1351
2639
  body: bodyOf({ name }),
1352
2640
  }),
1353
2641
  /**
@@ -1356,7 +2644,8 @@ class PlaidClient {
1356
2644
  * @param {string} direction - The direction ("up" or "down")
1357
2645
  */
1358
2646
  shift: (textLayerId, direction, auditMessage) =>
1359
- this._request('POST', `/api/v1/text-layers/${textLayerId}/shift`, { auditMessage,
2647
+ this._request("POST", `/api/v1/text-layers/${textLayerId}/shift`, {
2648
+ auditMessage,
1360
2649
  body: bodyOf({ direction }),
1361
2650
  }),
1362
2651
  /**
@@ -1364,9 +2653,10 @@ class PlaidClient {
1364
2653
  * @param {string} projectId - The project ID
1365
2654
  * @param {string} name - The name
1366
2655
  */
1367
- create: (projectId, name, auditMessage) =>
1368
- this._request('POST', '/api/v1/text-layers', { auditMessage,
1369
- body: bodyOf({ 'project-id': projectId, name }),
2656
+ create: (projectId, name, auditMessage, { id } = {}) =>
2657
+ this._request("POST", "/api/v1/text-layers", {
2658
+ auditMessage,
2659
+ body: bodyOf({ id, "project-id": projectId, name }),
1370
2660
  }),
1371
2661
  };
1372
2662
 
@@ -1377,25 +2667,27 @@ class PlaidClient {
1377
2667
  * @param {any} body - The request body
1378
2668
  */
1379
2669
  setMetadata: (id, body, auditMessage) =>
1380
- this._request('PUT', `/api/v1/vocab-items/${id}/metadata`, { auditMessage,
1381
- rawBody: body, skipResponseTransform: true,
2670
+ this._request("PUT", `/api/v1/vocab-items/${id}/metadata`, {
2671
+ auditMessage,
2672
+ rawBody: body,
1382
2673
  }),
1383
2674
  /**
1384
2675
  * Remove all metadata from a vocab item.
1385
2676
  * @param {string} id - The resource ID
1386
2677
  */
1387
2678
  deleteMetadata: (id, auditMessage) =>
1388
- this._request('DELETE', `/api/v1/vocab-items/${id}/metadata`, { auditMessage,
1389
- skipResponseTransform: true,
2679
+ this._request("DELETE", `/api/v1/vocab-items/${id}/metadata`, {
2680
+ auditMessage,
1390
2681
  }),
1391
2682
  /**
1392
- * Patch (shallow-merge) metadata for a vocab item. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
2683
+ * Edit metadata for a vocab item with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
1393
2684
  * @param {string} id - The resource ID
1394
- * @param {any} body - The metadata patch
2685
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
1395
2686
  */
1396
2687
  patchMetadata: (id, body, auditMessage) =>
1397
- this._request('PATCH', `/api/v1/vocab-items/${id}/metadata`, { auditMessage,
1398
- rawBody: body, skipResponseTransform: true,
2688
+ this._request("PATCH", `/api/v1/vocab-items/${id}/metadata`, {
2689
+ auditMessage,
2690
+ rawBody: body,
1399
2691
  }),
1400
2692
  /**
1401
2693
  * Create a new vocab item
@@ -1403,9 +2695,10 @@ class PlaidClient {
1403
2695
  * @param {string} form - The vocab item form
1404
2696
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
1405
2697
  */
1406
- create: (vocabLayerId, form, metadata, auditMessage) =>
1407
- this._request('POST', '/api/v1/vocab-items', { auditMessage,
1408
- body: bodyOf({ 'vocab-layer-id': vocabLayerId, form, metadata }),
2698
+ create: (vocabLayerId, form, metadata, auditMessage, { id } = {}) =>
2699
+ this._request("POST", "/api/v1/vocab-items", {
2700
+ auditMessage,
2701
+ body: bodyOf({ id, "vocab-layer-id": vocabLayerId, form, metadata }),
1409
2702
  }),
1410
2703
  /**
1411
2704
  * Create multiple vocab items in a single operation. Entries may target
@@ -1413,36 +2706,85 @@ class PlaidClient {
1413
2706
  * @param {Array<{vocabLayerId: string, form: string, metadata?: any}>} body - The vocab items to create
1414
2707
  */
1415
2708
  bulkCreate: (body, auditMessage) =>
1416
- this._request('POST', '/api/v1/vocab-items/bulk', { auditMessage, body }),
2709
+ this._request("POST", "/api/v1/vocab-items/bulk", {
2710
+ auditMessage,
2711
+ body,
2712
+ }),
2713
+ /**
2714
+ * Update many vocab items in a single operation: set forms and/or patch
2715
+ * metadata.
2716
+ * @param {Array<{id: string, form?: string, metadata?: Array<object>}>} body - `form` is set only when the key is present; `metadata` is a list of metadata ops, as for patchMetadata. The entries may lie in several vocab layers; the user must have write access to each. An unknown id refuses the whole update, and an id may appear only once. Only an entry whose form really changes restates the documents linking it, and a strict-mode client picks up their new versions from the response.
2717
+ * @returns {Promise<{count: number}>} How many vocab items were updated.
2718
+ */
2719
+ bulkUpdate: (body, auditMessage) =>
2720
+ this._request("PATCH", "/api/v1/vocab-items/bulk", {
2721
+ auditMessage,
2722
+ body,
2723
+ }),
1417
2724
  /**
1418
2725
  * Delete multiple vocab items in a single operation. Each item's
1419
2726
  * descendant vocab links are deleted too. Provide an array of IDs.
2727
+ *
2728
+ * Every document holding one of those links has its version bumped, and
2729
+ * a strict-mode client picks up their new versions from the response.
1420
2730
  * @param {string[]} body - The vocab item IDs to delete
1421
2731
  */
1422
2732
  bulkDelete: (body, auditMessage) =>
1423
- this._request('DELETE', '/api/v1/vocab-items/bulk', { auditMessage, body }),
2733
+ this._request("DELETE", "/api/v1/vocab-items/bulk", {
2734
+ auditMessage,
2735
+ body,
2736
+ }),
1424
2737
  /**
1425
2738
  * Get a vocab item by ID
1426
2739
  * @param {string} id - The resource ID
1427
- * @param {string} [asOf] - Temporal query timestamp
1428
2740
  */
1429
- get: (id, asOf) =>
1430
- this._request('GET', `/api/v1/vocab-items/${id}`, {
1431
- queryParams: { 'as-of': asOf },
1432
- }),
2741
+ get: (id) =>
2742
+ this._request("GET", `/api/v1/vocab-items/${id}`),
1433
2743
  /**
1434
- * Delete a vocab item
2744
+ * Delete a vocab item, and every link to it. Every document holding one
2745
+ * of those links has its version bumped, and a strict-mode client picks
2746
+ * up their new versions from the response.
1435
2747
  * @param {string} id - The resource ID
1436
- */
1437
- delete: (id, auditMessage) =>
1438
- this._request('DELETE', `/api/v1/vocab-items/${id}`, { auditMessage }),
2748
+ * @param {string} [auditMessage] - Audit message for this write
2749
+ * @param {{expectedLinkCount?: number}} [options] - `expectedLinkCount`: the
2750
+ * number of links the caller showed. The delete is refused with a 409 when
2751
+ * the entry has any other number of links by then.
2752
+ */
2753
+ delete: (id, auditMessage, { expectedLinkCount } = {}) =>
2754
+ this._request("DELETE", `/api/v1/vocab-items/${id}`, {
2755
+ auditMessage,
2756
+ queryParams: { "expected-link-count": expectedLinkCount },
2757
+ }),
1439
2758
  /**
1440
- * Update a vocab item's form
2759
+ * Merge entries into this one, in one operation. Every link to a loser
2760
+ * moves to the survivor (keeping its id and metadata), except a link on
2761
+ * words the survivor is already linked to, which is deleted, and then the
2762
+ * losers are deleted. Links are read when the merge runs, so one made
2763
+ * after the caller looked moves too. Every loser must be in the
2764
+ * survivor's vocabulary, and a loser that is already gone is skipped, so
2765
+ * a repeated merge changes nothing. References to a loser in other
2766
+ * entries' metadata are the caller's to rewrite, in the same batch.
2767
+ * Needs maintainer rights on the vocabulary.
2768
+ * @param {string} survivorId - The entry that stays
2769
+ * @param {string[]} loserIds - The entries merged into it
2770
+ * @returns {Promise<{moved: number, duplicates: number, removed: string[]}>}
2771
+ */
2772
+ merge: (survivorId, loserIds, auditMessage) =>
2773
+ this._request("POST", `/api/v1/vocab-items/${survivorId}/merge`, {
2774
+ auditMessage,
2775
+ body: { losers: loserIds },
2776
+ }),
2777
+ /**
2778
+ * Update a vocab item's form. A document read carries the entry's form on
2779
+ * every link to it, so a rename restates those documents: each has its
2780
+ * version bumped, and a strict-mode client picks up their new versions
2781
+ * from the response.
1441
2782
  * @param {string} id - The resource ID
1442
2783
  * @param {string} form - The vocab item form
1443
2784
  */
1444
2785
  update: (id, form, auditMessage) =>
1445
- this._request('PATCH', `/api/v1/vocab-items/${id}`, { auditMessage,
2786
+ this._request("PATCH", `/api/v1/vocab-items/${id}`, {
2787
+ auditMessage,
1446
2788
  body: bodyOf({ form }),
1447
2789
  }),
1448
2790
  };
@@ -1454,17 +2796,20 @@ class PlaidClient {
1454
2796
  * @param {string} direction - The direction ("up" or "down")
1455
2797
  */
1456
2798
  shift: (relationLayerId, direction, auditMessage) =>
1457
- this._request('POST', `/api/v1/relation-layers/${relationLayerId}/shift`, { auditMessage,
1458
- body: bodyOf({ direction }),
1459
- }),
2799
+ this._request(
2800
+ "POST",
2801
+ `/api/v1/relation-layers/${relationLayerId}/shift`,
2802
+ { auditMessage, body: bodyOf({ direction }) },
2803
+ ),
1460
2804
  /**
1461
2805
  * Create a new relation layer.
1462
2806
  * @param {string} spanLayerId - The span layer ID
1463
2807
  * @param {string} name - The name
1464
2808
  */
1465
- create: (spanLayerId, name, auditMessage) =>
1466
- this._request('POST', '/api/v1/relation-layers', { auditMessage,
1467
- body: bodyOf({ 'span-layer-id': spanLayerId, name }),
2809
+ create: (spanLayerId, name, auditMessage, { id } = {}) =>
2810
+ this._request("POST", "/api/v1/relation-layers", {
2811
+ auditMessage,
2812
+ body: bodyOf({ id, "span-layer-id": spanLayerId, name }),
1468
2813
  }),
1469
2814
  /**
1470
2815
  * Set a configuration value for a layer in an editor namespace.
@@ -1472,43 +2817,53 @@ class PlaidClient {
1472
2817
  * @param {string} namespace - The config namespace
1473
2818
  * @param {string} configKey - The config key
1474
2819
  * @param {any} configValue - Configuration value to set
2820
+ * @param {string} [auditMessage] - Audit message for this write
2821
+ * @param {{expected?: any}} [options] - `expected`: the value read for this key (null or undefined when it
2822
+ * was absent). The write is then refused with a 409 when the stored value differs.
1475
2823
  */
1476
- setConfig: (relationLayerId, namespace, configKey, configValue, auditMessage) =>
1477
- this._request('PUT', `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1478
- rawBody: configValue, skipResponseTransform: true,
1479
- }),
2824
+ setConfig: (relationLayerId, namespace, configKey, configValue, auditMessage, options) =>
2825
+ this._request(
2826
+ "PUT",
2827
+ `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`,
2828
+ configRequest(auditMessage, configValue, options),
2829
+ ),
1480
2830
  /**
1481
2831
  * Remove a configuration value for a layer.
1482
2832
  * @param {string} relationLayerId - The relation layer ID
1483
2833
  * @param {string} namespace - The config namespace
1484
2834
  * @param {string} configKey - The config key
2835
+ * @param {string} [auditMessage] - Audit message for this write
2836
+ * @param {{expected?: any}} [options] - `expected`, as on setConfig.
1485
2837
  */
1486
- deleteConfig: (relationLayerId, namespace, configKey, auditMessage) =>
1487
- this._request('DELETE', `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`, { auditMessage,
1488
- skipResponseTransform: true,
1489
- }),
2838
+ deleteConfig: (relationLayerId, namespace, configKey, auditMessage, options) =>
2839
+ this._request(
2840
+ "DELETE",
2841
+ `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`,
2842
+ configRequest(auditMessage, undefined, options, true),
2843
+ ),
2844
+ ...constraintMethods(this, "relation-layers"),
1490
2845
  /**
1491
2846
  * Get a relation layer by ID.
1492
2847
  * @param {string} relationLayerId - The relation layer ID
1493
- * @param {string} [asOf] - Temporal query timestamp
1494
2848
  */
1495
- get: (relationLayerId, asOf) =>
1496
- this._request('GET', `/api/v1/relation-layers/${relationLayerId}`, {
1497
- queryParams: { 'as-of': asOf },
1498
- }),
2849
+ get: (relationLayerId) =>
2850
+ this._request("GET", `/api/v1/relation-layers/${relationLayerId}`),
1499
2851
  /**
1500
2852
  * Delete a relation layer.
1501
2853
  * @param {string} relationLayerId - The relation layer ID
1502
2854
  */
1503
2855
  delete: (relationLayerId, auditMessage) =>
1504
- this._request('DELETE', `/api/v1/relation-layers/${relationLayerId}`, { auditMessage }),
2856
+ this._request("DELETE", `/api/v1/relation-layers/${relationLayerId}`, {
2857
+ auditMessage,
2858
+ }),
1505
2859
  /**
1506
2860
  * Update a relation layer's name.
1507
2861
  * @param {string} relationLayerId - The relation layer ID
1508
2862
  * @param {string} name - The name
1509
2863
  */
1510
2864
  update: (relationLayerId, name, auditMessage) =>
1511
- this._request('PATCH', `/api/v1/relation-layers/${relationLayerId}`, { auditMessage,
2865
+ this._request("PATCH", `/api/v1/relation-layers/${relationLayerId}`, {
2866
+ auditMessage,
1512
2867
  body: bodyOf({ name }),
1513
2868
  }),
1514
2869
  };
@@ -1532,26 +2887,41 @@ class PlaidClient {
1532
2887
  * @param {number} [precedence] - Ordering precedence
1533
2888
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
1534
2889
  */
1535
- create: (tokenLayerId, text, begin, end, precedence, metadata, auditMessage) =>
1536
- this._request('POST', '/api/v1/tokens', { auditMessage,
1537
- body: bodyOf({ 'token-layer-id': tokenLayerId, text, begin, end, precedence, metadata }),
2890
+ create: (
2891
+ tokenLayerId,
2892
+ text,
2893
+ begin,
2894
+ end,
2895
+ precedence,
2896
+ metadata,
2897
+ auditMessage,
2898
+ { id } = {},
2899
+ ) =>
2900
+ this._request("POST", "/api/v1/tokens", {
2901
+ auditMessage,
2902
+ body: bodyOf({
2903
+ id,
2904
+ "token-layer-id": tokenLayerId,
2905
+ text,
2906
+ begin,
2907
+ end,
2908
+ precedence,
2909
+ metadata,
2910
+ }),
1538
2911
  }),
1539
2912
  /**
1540
2913
  * Get a token.
1541
2914
  * @param {string} tokenId - The token ID
1542
- * @param {string} [asOf] - Temporal query timestamp
1543
2915
  */
1544
- get: (tokenId, asOf) =>
1545
- this._request('GET', `/api/v1/tokens/${tokenId}`, {
1546
- queryParams: { 'as-of': asOf },
1547
- }),
2916
+ get: (tokenId) =>
2917
+ this._request("GET", `/api/v1/tokens/${tokenId}`),
1548
2918
  /**
1549
2919
  * Delete a token and remove it from any spans. If this causes a span to
1550
2920
  * have no remaining tokens, the span will also be deleted.
1551
2921
  * @param {string} tokenId - The token ID
1552
2922
  */
1553
2923
  delete: (tokenId, auditMessage) =>
1554
- this._request('DELETE', `/api/v1/tokens/${tokenId}`, { auditMessage }),
2924
+ this._request("DELETE", `/api/v1/tokens/${tokenId}`, { auditMessage }),
1555
2925
  /**
1556
2926
  * Update a token.
1557
2927
  * @param {string} tokenId - The token ID
@@ -1563,7 +2933,8 @@ class PlaidClient {
1563
2933
  * drops undefined, so the three cases map correctly to the server.
1564
2934
  */
1565
2935
  update: (tokenId, begin, end, precedence, auditMessage) =>
1566
- this._request('PATCH', `/api/v1/tokens/${tokenId}`, { auditMessage,
2936
+ this._request("PATCH", `/api/v1/tokens/${tokenId}`, {
2937
+ auditMessage,
1567
2938
  body: bodyOf({ begin, end, precedence }),
1568
2939
  }),
1569
2940
  /**
@@ -1572,22 +2943,43 @@ class PlaidClient {
1572
2943
  * @returns {Promise<{ids: string[]}>} The created entity IDs, in input order. (bulkDelete resolves to no value.)
1573
2944
  */
1574
2945
  bulkCreate: (body, auditMessage) =>
1575
- this._request('POST', '/api/v1/tokens/bulk', { auditMessage, body }),
2946
+ this._request("POST", "/api/v1/tokens/bulk", { auditMessage, body }),
1576
2947
  /**
1577
2948
  * Delete multiple tokens in a single operation. Provide an array of IDs.
1578
2949
  * @param {Array} body - The request body
1579
2950
  */
1580
2951
  bulkDelete: (body, auditMessage) =>
1581
- this._request('DELETE', '/api/v1/tokens/bulk', { auditMessage, body }),
2952
+ this._request("DELETE", "/api/v1/tokens/bulk", { auditMessage, body }),
2953
+ /**
2954
+ * Update many tokens in a single operation: patch metadata.
2955
+ * @param {Array} body - Objects of the shape `{id, metadata}`. `metadata` is a list of metadata ops, as for patchMetadata. The tokens may lie in several documents of one project; every document touched has its version bumped and every new version comes back in `X-Document-Versions` (past fifty documents, only their number, in `X-Document-Versions-Omitted`). A `document-version` precondition is accepted only when every entry lies in one document. An unknown id refuses the whole update.
2956
+ * @returns {Promise<{count: number}>} How many tokens were updated.
2957
+ */
2958
+ bulkUpdate: (body, auditMessage) =>
2959
+ this._request("PATCH", "/api/v1/tokens/bulk", {
2960
+ auditMessage,
2961
+ body,
2962
+ }),
1582
2963
  /**
1583
2964
  * Split a token at a Unicode code-point offset. The original token becomes the
1584
2965
  * left half (keeps its ID, spans, vocab-links); the new right token's ID is returned.
1585
2966
  * @param {string} tokenId - The token ID
1586
2967
  * @param {number} position - Code-point offset to split at (strictly between begin and end)
1587
- */
1588
- split: (tokenId, position, auditMessage) =>
1589
- this._request('POST', `/api/v1/tokens/${tokenId}/split`, { auditMessage,
1590
- body: bodyOf({ position }),
2968
+ * @param {string} [auditMessage] - Audit message for this write
2969
+ * @param {{id?: string}} [options] - `id`: the right half's id, a UUIDv7 this
2970
+ * client minted. A relation layer whose relations must stay inside one token
2971
+ * of this layer declares a same-ancestor constraint, and the server deletes
2972
+ * the relations the split leaves crossing in the same transaction.
2973
+ */
2974
+ split: (
2975
+ tokenId,
2976
+ position,
2977
+ auditMessage,
2978
+ { id } = {},
2979
+ ) =>
2980
+ this._request("POST", `/api/v1/tokens/${tokenId}/split`, {
2981
+ auditMessage,
2982
+ body: bodyOf({ id, position }),
1591
2983
  }),
1592
2984
  /**
1593
2985
  * Merge two tokens. The left token (smaller begin) survives with the combined
@@ -1598,8 +2990,9 @@ class PlaidClient {
1598
2990
  * @param {string} otherTokenId - The other token to merge in
1599
2991
  */
1600
2992
  merge: (tokenId, otherTokenId, auditMessage) =>
1601
- this._request('POST', `/api/v1/tokens/${tokenId}/merge`, { auditMessage,
1602
- body: bodyOf({ 'other-token-id': otherTokenId }),
2993
+ this._request("POST", `/api/v1/tokens/${tokenId}/merge`, {
2994
+ auditMessage,
2995
+ body: bodyOf({ "other-token-id": otherTokenId }),
1603
2996
  }),
1604
2997
  /**
1605
2998
  * Shift a token's boundary. On partitioning layers the adjacent token is
@@ -1610,7 +3003,8 @@ class PlaidClient {
1610
3003
  * @param {number} [end] - New end offset, exclusive (Unicode code points)
1611
3004
  */
1612
3005
  shift: (tokenId, begin, end, auditMessage) =>
1613
- this._request('POST', `/api/v1/tokens/${tokenId}/shift`, { auditMessage,
3006
+ this._request("POST", `/api/v1/tokens/${tokenId}/shift`, {
3007
+ auditMessage,
1614
3008
  body: bodyOf({ begin, end }),
1615
3009
  }),
1616
3010
  /**
@@ -1619,25 +3013,27 @@ class PlaidClient {
1619
3013
  * @param {any} body - The request body
1620
3014
  */
1621
3015
  setMetadata: (tokenId, body, auditMessage) =>
1622
- this._request('PUT', `/api/v1/tokens/${tokenId}/metadata`, { auditMessage,
1623
- rawBody: body, skipResponseTransform: true,
3016
+ this._request("PUT", `/api/v1/tokens/${tokenId}/metadata`, {
3017
+ auditMessage,
3018
+ rawBody: body,
1624
3019
  }),
1625
3020
  /**
1626
3021
  * Remove all metadata from a token.
1627
3022
  * @param {string} tokenId - The token ID
1628
3023
  */
1629
3024
  deleteMetadata: (tokenId, auditMessage) =>
1630
- this._request('DELETE', `/api/v1/tokens/${tokenId}/metadata`, { auditMessage,
1631
- skipResponseTransform: true,
3025
+ this._request("DELETE", `/api/v1/tokens/${tokenId}/metadata`, {
3026
+ auditMessage,
1632
3027
  }),
1633
3028
  /**
1634
- * Patch (shallow-merge) metadata for a token. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
3029
+ * Edit metadata for a token with a list of ops applied in order, in one operation. `{op: 'set', path, value}` writes value at path, creating missing objects along it; `{op: 'delete', path}` removes the key at path (a no-op when absent). A path is a non-empty array of keys, the first a top-level key. A path through a non-object is refused (400). See metadataOps and applyMetadataOps.
1635
3030
  * @param {string} tokenId - The token ID
1636
- * @param {any} body - The metadata patch
3031
+ * @param {Array<{op: string, path: string[], value?: any}>} body - The metadata ops
1637
3032
  */
1638
3033
  patchMetadata: (tokenId, body, auditMessage) =>
1639
- this._request('PATCH', `/api/v1/tokens/${tokenId}/metadata`, { auditMessage,
1640
- rawBody: body, skipResponseTransform: true,
3034
+ this._request("PATCH", `/api/v1/tokens/${tokenId}/metadata`, {
3035
+ auditMessage,
3036
+ rawBody: body,
1641
3037
  }),
1642
3038
  };
1643
3039
 
@@ -1661,12 +3057,17 @@ class PlaidClient {
1661
3057
  * this a key such as `case-marker` would reach listeners as
1662
3058
  * `caseMarker` in JS and `case_marker` in Python. `listen` restores it
1663
3059
  * verbatim on the way in.
3060
+ *
3061
+ * A message is not saved and writes nothing to History, so it never
3062
+ * joins an open operation (`withOperation`). Made on a batch it still
3063
+ * queues, so a message queued after the writes goes out after them.
1664
3064
  * @param {string} projectId - The UUID of the project to send to
1665
3065
  * @param {any} data - The message data to send
1666
3066
  * @returns {Promise<any>} Response from the send operation
1667
3067
  */
1668
- sendMessage: (projectId, data, auditMessage) =>
1669
- this._request('POST', `/api/v1/projects/${projectId}/message`, { auditMessage,
3068
+ sendMessage: (projectId, data) =>
3069
+ this._request("POST", `/api/v1/projects/${projectId}/message`, {
3070
+ noOperation: true,
1670
3071
  rawBody: { body: data },
1671
3072
  }),
1672
3073
 
@@ -1677,8 +3078,7 @@ class PlaidClient {
1677
3078
  * @param {string} projectId - The UUID of the project to query
1678
3079
  * @returns {Promise<Array>} Array of discovered service information
1679
3080
  */
1680
- discoverServices: (projectId) =>
1681
- discoverServices(this, projectId),
3081
+ discoverServices: (projectId) => discoverServices(this, projectId),
1682
3082
 
1683
3083
  /**
1684
3084
  * Forget a previously-seen (offline) service. Maintainer-only; 409 if
@@ -1692,26 +3092,102 @@ class PlaidClient {
1692
3092
 
1693
3093
  /**
1694
3094
  * Register as a service and handle incoming work requests.
3095
+ *
3096
+ * The registration reopens its channel whenever it drops, so a server
3097
+ * restart needs no service restart.
1695
3098
  * @param {string} projectId - The UUID of the project to serve
1696
3099
  * @param {Object} serviceInfo - Service information {serviceId, serviceName, description}
1697
3100
  * @param {function} onServiceRequest - Callback (data, responseHelper)
1698
3101
  * @param {Object} [extras] - Optional additional service metadata
1699
- * @returns {Object} Service registration object with .stop() method
1700
- */
1701
- serve: (projectId, serviceInfo, onServiceRequest, extras) =>
1702
- serve(this, projectId, serviceInfo, onServiceRequest, extras),
3102
+ * @param {function} [onStatus] - Optional callback (event, projectId, detail) for
3103
+ * connection transitions: 'registered', 'reconnected', 'disconnected',
3104
+ * and 'stopped' when the server refuses the channel for good, which
3105
+ * ends the registration
3106
+ * @returns {Object} Service registration with .stop(), .isRunning(), .isConnected()
3107
+ */
3108
+ serve: (projectId, serviceInfo, onServiceRequest, extras, onStatus) =>
3109
+ serve(this, projectId, serviceInfo, onServiceRequest, extras, onStatus),
1703
3110
 
1704
3111
  /**
1705
3112
  * Request a service to perform work and await its result.
3113
+ *
3114
+ * The request outlives this call: after a timeout, an abort, or a
3115
+ * dropped connection the service goes on, and `attachServiceRequest`
3116
+ * collects the result given the request id, which `opts.onAccepted`
3117
+ * receives as soon as the server has taken the request. Pass
3118
+ * `opts.requestId` (a UUID you mint) to know the id before submitting;
3119
+ * submitting an id that names a request you already made rejoins it
3120
+ * instead of starting another.
1706
3121
  * @param {string} projectId - The UUID of the project
1707
3122
  * @param {string} serviceId - The ID of the service to request
1708
3123
  * @param {any} data - The request data
1709
- * @param {number} [timeout] - Timeout in milliseconds (default: 10000)
3124
+ * @param {number} [timeout=10000] - Timeout in ms
1710
3125
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
1711
- * @returns {Promise<any>} Service response
3126
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
3127
+ * @param {Object} [opts] - {requestId, onAccepted, projectIds, noOperation}
3128
+ * @returns {Promise<any>} The service's result
3129
+ */
3130
+ requestService: (
3131
+ projectId,
3132
+ serviceId,
3133
+ data,
3134
+ timeout,
3135
+ onProgress,
3136
+ signal,
3137
+ opts,
3138
+ ) =>
3139
+ requestService(
3140
+ this,
3141
+ projectId,
3142
+ serviceId,
3143
+ data,
3144
+ timeout,
3145
+ onProgress,
3146
+ signal,
3147
+ opts,
3148
+ ),
3149
+
3150
+ /**
3151
+ * Rejoin a service request made earlier and await its result: the
3152
+ * latest progress is replayed, then the result comes, or at once if the
3153
+ * request already finished. Only the user who submitted it (or an
3154
+ * admin). Rejects with an error whose `status` is 404 when the request
3155
+ * is unknown or expired (a finished request's result is kept for a
3156
+ * while, not forever).
3157
+ * @param {string} projectId - The UUID of the project
3158
+ * @param {string} requestId - The request id
3159
+ * @param {number} [timeout=10000] - Timeout in ms
3160
+ * @param {function} [onProgress] - Called with each progress payload
3161
+ * @param {AbortSignal} [signal] - Abort to stop waiting
3162
+ * @returns {Promise<any>} The service's result
3163
+ */
3164
+ attachServiceRequest: (
3165
+ projectId,
3166
+ requestId,
3167
+ timeout,
3168
+ onProgress,
3169
+ signal,
3170
+ ) =>
3171
+ attachServiceRequest(
3172
+ this,
3173
+ projectId,
3174
+ requestId,
3175
+ timeout,
3176
+ onProgress,
3177
+ signal,
3178
+ ),
3179
+
3180
+ /**
3181
+ * Ask the service to stop a request made earlier. The request still
3182
+ * ends with whatever the service then reports, on the stream of whoever
3183
+ * is awaiting it. Rejects with 404 if unknown or expired, 409 once
3184
+ * finished.
3185
+ * @param {string} projectId - The UUID of the project
3186
+ * @param {string} requestId - The request id
3187
+ * @returns {Promise<void>}
1712
3188
  */
1713
- requestService: (projectId, serviceId, data, timeout, onProgress) =>
1714
- requestService(this, projectId, serviceId, data, timeout, onProgress),
3189
+ cancelServiceRequest: (projectId, requestId) =>
3190
+ cancelServiceRequest(this, projectId, requestId),
1715
3191
  };
1716
3192
 
1717
3193
  /**
@@ -1746,11 +3222,15 @@ class PlaidClient {
1746
3222
  * @param {Object} body - The query AST ({find, where, scope?, limit?, orderBy?,
1747
3223
  * return?, bindings?}).
1748
3224
  * @returns {Promise<Object>} For 'ids'/'entities': {columns, results, count, truncated}.
1749
- * For 'count': {return: 'count', count}. Entity cells are full entity objects
1750
- * (same shape as the GET endpoints).
3225
+ * For 'count': {return: 'count', count, truncated}, where `truncated` says
3226
+ * the count stopped at the server's cap. Entity cells are full entity
3227
+ * objects (same shape as the GET endpoints).
1751
3228
  */
1752
- this.query = (body, auditMessage) =>
1753
- this._request('POST', '/api/v1/query', { auditMessage, body });
3229
+ // outOfBand: a query is a read that travels as a POST (see the note at
3230
+ // the top of http.js). Made on a batch it goes over the wire like any
3231
+ // read, and it never joins a logical operation.
3232
+ this.query = (body) =>
3233
+ this._request("POST", "/api/v1/query", { body, outOfBand: true });
1754
3234
 
1755
3235
  // Logical-operation groups (audit-log grouping). There is no create: a
1756
3236
  // group row is made lazily by the first write carrying `?group-id=`
@@ -1760,15 +3240,406 @@ class PlaidClient {
1760
3240
  * Get a logical-operation group (its label + creator).
1761
3241
  * @param {string} id - The group id
1762
3242
  */
1763
- get: (id) =>
1764
- this._request('GET', `/api/v1/operation-groups/${id}`),
3243
+ get: (id) => this._request("GET", `/api/v1/operation-groups/${id}`),
1765
3244
  /**
1766
3245
  * Relabel a logical-operation group after the fact. Owner or admin only.
1767
3246
  * @param {string} id - The group id
1768
3247
  * @param {string|null} message - The new label
1769
3248
  */
1770
3249
  update: (id, message) =>
1771
- this._request('PATCH', `/api/v1/operation-groups/${id}`, { body: { message } }),
3250
+ this._request("PATCH", `/api/v1/operation-groups/${id}`, {
3251
+ body: { message },
3252
+ }),
3253
+ };
3254
+
3255
+ this.comments = {
3256
+ /**
3257
+ * Post a comment on an entity. Requires write access to the entity's
3258
+ * project, or to the vocabulary for a vocab-item; the author is the
3259
+ * authenticated caller. Comments are not audited and do not bump the
3260
+ * document version.
3261
+ *
3262
+ * A comment outlives its anchor: deleting the entity does not delete
3263
+ * the comment. `anchorLabel` is the caption shown once that happens
3264
+ * ("Gloss of ktab, sentence 4"), so pass what the comment is about.
3265
+ * @param {'document'|'text'|'token'|'span'|'relation'|'vocab-item'} entityType - What kind of thing is being commented on
3266
+ * @param {string} entityId - The commented entity's id
3267
+ * @param {string} body - The comment text (1..10000 characters)
3268
+ * @param {object} [opts]
3269
+ * @param {string} [opts.anchorLabel] - What the comment is about, in words (at most 200 characters)
3270
+ */
3271
+ create: (entityType, entityId, body, { anchorLabel, id } = {}) =>
3272
+ this._request("POST", "/api/v1/comments", {
3273
+ // A comment is not audited, so it never joins an open operation,
3274
+ // and never takes a number from an edit's Idempotency-Key seed.
3275
+ noOperation: true,
3276
+ body: bodyOf({
3277
+ id,
3278
+ "entity-type": entityType,
3279
+ "entity-id": entityId,
3280
+ body,
3281
+ "anchor-label": anchorLabel,
3282
+ }),
3283
+ }),
3284
+ /**
3285
+ * Read one comment.
3286
+ * @param {string} id - The comment id
3287
+ */
3288
+ get: (id) => this._request("GET", `/api/v1/comments/${id}`),
3289
+ /**
3290
+ * Edit a comment's body. Only the comment's AUTHOR may do this - not
3291
+ * maintainers, not admins. Sets `edited` on the comment.
3292
+ * @param {string} id - The comment id
3293
+ * @param {string} body - The replacement text
3294
+ */
3295
+ update: (id, body) =>
3296
+ this._request("PATCH", `/api/v1/comments/${id}`, {
3297
+ body: { body },
3298
+ noOperation: true,
3299
+ }),
3300
+ /**
3301
+ * Delete a comment. The author may delete their own; a project
3302
+ * maintainer (or admin) may delete any.
3303
+ * @param {string} id - The comment id
3304
+ */
3305
+ delete: (id) =>
3306
+ this._request("DELETE", `/api/v1/comments/${id}`, { noOperation: true }),
3307
+ /**
3308
+ * List comments in a project, oldest first. Transparently follows
3309
+ * pagination cursors and returns the full flat array.
3310
+ * @param {string} projectId - The project to read
3311
+ * @param {object} [filters] - Narrow the scope
3312
+ * @param {string} [filters.documentId] - Every comment anywhere in one document
3313
+ * @param {string} [filters.entityType] - With entityId, one entity's thread
3314
+ * @param {string} [filters.entityId] - With entityType, one entity's thread
3315
+ */
3316
+ list: (projectId, { documentId, entityType, entityId } = {}) =>
3317
+ listAll(this, `/api/v1/projects/${projectId}/comments`, {
3318
+ query: {
3319
+ "document-id": documentId,
3320
+ "entity-type": entityType,
3321
+ "entity-id": entityId,
3322
+ },
3323
+ }),
3324
+ /**
3325
+ * Fetch a single page of a project's comments.
3326
+ * @param {string} projectId - The project to read
3327
+ * @param {object} [opts]
3328
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
3329
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
3330
+ * @param {string} [opts.documentId] - Every comment anywhere in one document
3331
+ * @param {string} [opts.entityType] - With entityId, one entity's thread
3332
+ * @param {string} [opts.entityId] - With entityType, one entity's thread
3333
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
3334
+ */
3335
+ listPage: (
3336
+ projectId,
3337
+ { limit, cursor, documentId, entityType, entityId } = {},
3338
+ ) =>
3339
+ listPage(this, `/api/v1/projects/${projectId}/comments`, {
3340
+ limit,
3341
+ cursor,
3342
+ query: {
3343
+ "document-id": documentId,
3344
+ "entity-type": entityType,
3345
+ "entity-id": entityId,
3346
+ },
3347
+ }),
3348
+ /**
3349
+ * Async-iterate a project's comments page by page; yields each page's entries array.
3350
+ * @param {string} projectId - The project to read
3351
+ * @param {object} [opts]
3352
+ * @param {number} [opts.pageSize] - Per-request page size
3353
+ * @param {string} [opts.documentId] - Every comment anywhere in one document
3354
+ * @param {string} [opts.entityType] - With entityId, one entity's thread
3355
+ * @param {string} [opts.entityId] - With entityType, one entity's thread
3356
+ * @returns {AsyncGenerator<Array>}
3357
+ */
3358
+ iterPages: (
3359
+ projectId,
3360
+ { pageSize, documentId, entityType, entityId } = {},
3361
+ ) =>
3362
+ iterPages(this, `/api/v1/projects/${projectId}/comments`, {
3363
+ pageSize,
3364
+ query: {
3365
+ "document-id": documentId,
3366
+ "entity-type": entityType,
3367
+ "entity-id": entityId,
3368
+ },
3369
+ }),
3370
+ /**
3371
+ * Comment counts per entity, as an `{entityId: n}` map, over the same
3372
+ * scope and filters as `list`. One cheap request paints a comment
3373
+ * indicator on every annotated item in a document without paging
3374
+ * through the bodies.
3375
+ *
3376
+ * The response is NOT key-transformed: its keys are entity ids, and
3377
+ * camelCasing would mangle the hyphens in a UUID.
3378
+ * @param {string} projectId - The project to read
3379
+ * @param {object} [filters] - Same filters as list()
3380
+ * @returns {Promise<Object<string, number>>}
3381
+ */
3382
+ counts: (projectId, { documentId, entityType, entityId } = {}) =>
3383
+ this._request("GET", `/api/v1/projects/${projectId}/comments/counts`, {
3384
+ queryParams: {
3385
+ "document-id": documentId,
3386
+ "entity-type": entityType,
3387
+ "entity-id": entityId,
3388
+ },
3389
+ skipResponseTransform: true,
3390
+ }),
3391
+ /**
3392
+ * List the comments on a vocabulary's entries, oldest first. Requires
3393
+ * read access to the vocabulary. Transparently follows pagination
3394
+ * cursors and returns the full flat array.
3395
+ * @param {string} vocabId - The vocab layer to read
3396
+ * @param {object} [filters]
3397
+ * @param {string} [filters.entityId] - One entry's thread
3398
+ */
3399
+ listInVocab: (vocabId, { entityId } = {}) =>
3400
+ listAll(this, `/api/v1/vocab-layers/${vocabId}/comments`, {
3401
+ query: { "entity-id": entityId },
3402
+ }),
3403
+ /**
3404
+ * Fetch a single page of a vocabulary's comments.
3405
+ * @param {string} vocabId - The vocab layer to read
3406
+ * @param {object} [opts]
3407
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
3408
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
3409
+ * @param {string} [opts.entityId] - One entry's thread
3410
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
3411
+ */
3412
+ listInVocabPage: (vocabId, { limit, cursor, entityId } = {}) =>
3413
+ listPage(this, `/api/v1/vocab-layers/${vocabId}/comments`, {
3414
+ limit,
3415
+ cursor,
3416
+ query: { "entity-id": entityId },
3417
+ }),
3418
+ /**
3419
+ * Async-iterate a vocabulary's comments page by page, oldest first;
3420
+ * yields each page's entries array.
3421
+ * @param {string} vocabId - The vocab layer to read
3422
+ * @param {object} [opts]
3423
+ * @param {string} [opts.entityId] - One entry's thread
3424
+ * @param {number} [opts.pageSize] - Per-request page size
3425
+ * @returns {AsyncGenerator<Array>}
3426
+ */
3427
+ iterInVocabPages: (vocabId, { entityId, pageSize } = {}) =>
3428
+ iterPages(this, `/api/v1/vocab-layers/${vocabId}/comments`, {
3429
+ pageSize,
3430
+ query: { "entity-id": entityId },
3431
+ }),
3432
+ /**
3433
+ * Comment counts per entry of a vocabulary, as an `{entityId: n}` map.
3434
+ * The response is NOT key-transformed: its keys are entry ids.
3435
+ * @param {string} vocabId - The vocab layer to read
3436
+ * @param {object} [filters]
3437
+ * @param {string} [filters.entityId] - One entry only
3438
+ * @returns {Promise<Object<string, number>>}
3439
+ */
3440
+ countsInVocab: (vocabId, { entityId } = {}) =>
3441
+ this._request(
3442
+ "GET",
3443
+ `/api/v1/vocab-layers/${vocabId}/comments/counts`,
3444
+ {
3445
+ queryParams: { "entity-id": entityId },
3446
+ skipResponseTransform: true,
3447
+ },
3448
+ ),
3449
+ };
3450
+
3451
+ this.events = {
3452
+ /**
3453
+ * Record one research-telemetry event (core manual, "Research
3454
+ * telemetry"). Fire and forget: the event is buffered and sent with
3455
+ * others every ten seconds, at fifty events, and when the page is
3456
+ * hidden. A batch that fails is dropped, nothing is retried, and
3457
+ * nothing is ever thrown. Sends nothing for a project whose switch
3458
+ * (`config.plaid.research.telemetry`) is off, which it reads from the
3459
+ * project itself. `suggestion.shown` is recorded once per target,
3460
+ * field and value in a page session.
3461
+ * @param {'suggestion.shown'|'suggestion.adopted'|'suggestion.dismissed'|'plan.opened'} type - The event type
3462
+ * @param {object} fields
3463
+ * @param {string} fields.projectId - The project it happened in
3464
+ * @param {string} [fields.documentId] - The document it happened in
3465
+ * @param {string} [fields.targetId] - What it is about: a token, a plan
3466
+ * @param {object} [fields.data] - Its details, keyed by single lowercase words (`value`, `source`, `field`, `written`, `conversation`)
3467
+ * @returns {boolean} Whether the event was buffered
3468
+ */
3469
+ record: (type, fields) => recorderFor(this).record(this, type, fields),
3470
+ /**
3471
+ * Send what the recorder holds now, rather than at its next interval.
3472
+ */
3473
+ flush: () => recorderFor(this).flush(),
3474
+ /**
3475
+ * Tell the recorder a project's switch at once, e.g. right after a
3476
+ * maintainer changed it. Turning it off drops what is buffered.
3477
+ * @param {string} projectId - The project
3478
+ * @param {boolean} on - Whether telemetry is on
3479
+ */
3480
+ setEnabled: (projectId, on) => recorderFor(this).setEnabled(projectId, on),
3481
+ /**
3482
+ * Record events directly, with no buffering. Requires write access, and
3483
+ * the server refuses with a 403 while the project's switch is off. All
3484
+ * or nothing: one bad event refuses the request with a 400 naming it.
3485
+ * @param {string} projectId - The project the events happened in
3486
+ * @param {Array<{type: string, documentId?: string, targetId?: string, data?: object, clientTs?: string}>} events - At most 500 events
3487
+ * @returns {Promise<{count: number}>}
3488
+ */
3489
+ create: (projectId, events) =>
3490
+ this._request("POST", `/api/v1/projects/${projectId}/events`, {
3491
+ body: events,
3492
+ outOfBand: true,
3493
+ }),
3494
+ /**
3495
+ * A project's events in arrival order. Maintainer or admin only.
3496
+ * Transparently follows pagination cursors and returns the full flat
3497
+ * array.
3498
+ * @param {string} projectId - The project to read
3499
+ * @param {object} [filters]
3500
+ * @param {string|string[]} [filters.types] - Only these types
3501
+ * @param {string} [filters.startTime] - Only events the server stamped at or after this instant
3502
+ * @param {string} [filters.endTime] - Only events the server stamped at or before this instant
3503
+ */
3504
+ list: (projectId, { types, startTime, endTime } = {}) =>
3505
+ listAll(this, `/api/v1/projects/${projectId}/events`, {
3506
+ query: eventQuery(types, startTime, endTime),
3507
+ }),
3508
+ /**
3509
+ * One page of a project's events. Maintainer or admin only.
3510
+ * @param {string} projectId - The project to read
3511
+ * @param {object} [opts]
3512
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
3513
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
3514
+ * @param {string|string[]} [opts.types] - Only these types
3515
+ * @param {string} [opts.startTime] - Only events at or after this instant
3516
+ * @param {string} [opts.endTime] - Only events at or before this instant
3517
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
3518
+ */
3519
+ listPage: (
3520
+ projectId,
3521
+ { limit, cursor, types, startTime, endTime } = {},
3522
+ ) =>
3523
+ listPage(this, `/api/v1/projects/${projectId}/events`, {
3524
+ limit,
3525
+ cursor,
3526
+ query: eventQuery(types, startTime, endTime),
3527
+ }),
3528
+ /**
3529
+ * Async-iterate a project's events page by page; yields each page's entries array.
3530
+ * @param {string} projectId - The project to read
3531
+ * @param {object} [opts]
3532
+ * @param {number} [opts.pageSize] - Per-request page size
3533
+ * @param {string|string[]} [opts.types] - Only these types
3534
+ * @param {string} [opts.startTime] - Only events at or after this instant
3535
+ * @param {string} [opts.endTime] - Only events at or before this instant
3536
+ * @returns {AsyncGenerator<Array>}
3537
+ */
3538
+ iterPages: (projectId, { pageSize, types, startTime, endTime } = {}) =>
3539
+ iterPages(this, `/api/v1/projects/${projectId}/events`, {
3540
+ pageSize,
3541
+ query: eventQuery(types, startTime, endTime),
3542
+ }),
3543
+ };
3544
+
3545
+ this.guidelines = {
3546
+ /**
3547
+ * Create a guideline in a project. `title` is the handle an assistant
3548
+ * asks for one by, and the only thing a person has to keep current. It is
3549
+ * NOT required to be unique, so a title already in use is written like
3550
+ * any other: warn about it, do not refuse it. A `pinned` guideline is one
3551
+ * the assistant is given in full on every turn.
3552
+ * @param {string} projectId - The project the guideline belongs to
3553
+ * @param {string} title - The handle an assistant asks for one by (1..100 characters)
3554
+ * @param {object} [opts]
3555
+ * @param {string} [opts.body] - The Markdown text (up to 20000 characters; may be empty)
3556
+ * @param {boolean} [opts.pinned] - Send this one to the assistant in full on every turn
3557
+ */
3558
+ create: (projectId, title, { body, pinned, id } = {}, auditMessage) =>
3559
+ this._request("POST", `/api/v1/projects/${projectId}/guidelines`, {
3560
+ auditMessage,
3561
+ body: bodyOf({ id, title, body, pinned }),
3562
+ }),
3563
+ /**
3564
+ * Read one guideline, Markdown body included.
3565
+ * @param {string} id - The guideline id
3566
+ */
3567
+ get: (id) => this._request("GET", `/api/v1/guidelines/${id}`),
3568
+ /**
3569
+ * Update a guideline. Every field is optional and an omitted one is
3570
+ * left alone, so an edit to the body need not restate the title.
3571
+ *
3572
+ * Pass `expectedUpdatedAt` (the `updatedAt` you last read) when a person
3573
+ * has been editing prose: the write then fails with 409 rather than
3574
+ * overwriting somebody who saved in between. Leave it off for a pin
3575
+ * toggle or a script, which have nothing of anyone's to lose.
3576
+ * @param {string} id - The guideline id
3577
+ * @param {object} [changes]
3578
+ * @param {string} [changes.title] - The new handle
3579
+ * @param {string} [changes.body] - The new Markdown text
3580
+ * @param {boolean} [changes.pinned] - Whether the assistant always gets it in full
3581
+ * @param {string} [changes.expectedUpdatedAt] - Write only if this is still the stored updatedAt
3582
+ */
3583
+ update: (
3584
+ id,
3585
+ { title, body, pinned, expectedUpdatedAt } = {},
3586
+ auditMessage,
3587
+ ) =>
3588
+ this._request("PATCH", `/api/v1/guidelines/${id}`, {
3589
+ auditMessage,
3590
+ queryParams: { "updated-at": expectedUpdatedAt },
3591
+ body: bodyOf({ title, body, pinned }),
3592
+ }),
3593
+ /**
3594
+ * Delete a guideline.
3595
+ * @param {string} id - The guideline id
3596
+ */
3597
+ delete: (id, auditMessage) =>
3598
+ this._request("DELETE", `/api/v1/guidelines/${id}`, { auditMessage }),
3599
+ /**
3600
+ * List a project's guidelines, by title. Transparently follows
3601
+ * pagination cursors and returns the full flat array.
3602
+ *
3603
+ * Without `includeBodies` each entry carries `bodyChars`, the length of
3604
+ * its body, so a caller can budget before fetching any. Pinned
3605
+ * guidelines are NOT sorted first: `pinned` is on every entry and
3606
+ * grouping is the caller's.
3607
+ * @param {string} projectId - The project to read
3608
+ * @param {object} [opts]
3609
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3610
+ */
3611
+ list: (projectId, { includeBodies } = {}) =>
3612
+ listAll(this, `/api/v1/projects/${projectId}/guidelines`, {
3613
+ query: { "include-bodies": includeBodies },
3614
+ }),
3615
+ /**
3616
+ * Fetch a single page of a project's guidelines.
3617
+ * @param {string} projectId - The project to read
3618
+ * @param {object} [opts]
3619
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
3620
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
3621
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3622
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
3623
+ */
3624
+ listPage: (projectId, { limit, cursor, includeBodies } = {}) =>
3625
+ listPage(this, `/api/v1/projects/${projectId}/guidelines`, {
3626
+ limit,
3627
+ cursor,
3628
+ query: { "include-bodies": includeBodies },
3629
+ }),
3630
+ /**
3631
+ * Async-iterate a project's guidelines page by page; yields each page's entries array.
3632
+ * @param {string} projectId - The project to read
3633
+ * @param {object} [opts]
3634
+ * @param {number} [opts.pageSize] - Per-request page size
3635
+ * @param {boolean} [opts.includeBodies] - Return each body instead of its length
3636
+ * @returns {AsyncGenerator<Array>}
3637
+ */
3638
+ iterPages: (projectId, { pageSize, includeBodies } = {}) =>
3639
+ iterPages(this, `/api/v1/projects/${projectId}/guidelines`, {
3640
+ pageSize,
3641
+ query: { "include-bodies": includeBodies },
3642
+ }),
1772
3643
  };
1773
3644
  }
1774
3645
 
@@ -1811,25 +3682,80 @@ class PlaidClient {
1811
3682
  * write, so an operation that is never ended (crash, closed tab) is still
1812
3683
  * labeled in the log.
1813
3684
  *
3685
+ * "Every write" is the writes of project data. Reads never join, and neither
3686
+ * do the out-of-band signals shaped like a write (a document lock taken or
3687
+ * renewed, a stopped service request, a service reporting itself, an admin
3688
+ * control), and neither does a broadcast message (`messages.sendMessage`):
3689
+ * none of them is audited, so there would be nothing under the label.
3690
+ *
3691
+ * `kind` says what kind of operation this is, for a program reading the
3692
+ * log: one of `assistant-plan`, `service-run`, `import`, `bulk-edit`,
3693
+ * `guess-adoption`, `repair` or `review` (the server refuses any other).
3694
+ * `ref` is a short string naming what the operation came from, in the
3695
+ * shape its kind documents (the core manual, "Kinds of operation"). Both
3696
+ * are recorded from the first write like the label, and a nested operation
3697
+ * keeps the outer one's.
3698
+ *
3699
+ * `keys` (from `keySeed()`) makes a run of the operation send the same
3700
+ * requests as an earlier run with the same keys: the nth keyed request that
3701
+ * joins it takes the Idempotency-Key `<seed>.<n>` and the document-version
3702
+ * its first run claimed, so a request that landed is answered from its
3703
+ * first send and writes nothing again. The count starts at 0 at each
3704
+ * outermost begin. A nested operation joins the outer one's keys, unless it
3705
+ * brings its own: then it numbers from 0 under its own seed until its
3706
+ * matching end, and the outer numbering resumes after it.
3707
+ *
1814
3708
  * @param {string} message - Human label for the operation.
1815
- * @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).
3709
+ * @param {object} [opts] - Optional `{ id, kind, ref, keys }`. `id` adopts an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically). `kind`, `ref` and `keys` are described above.
1816
3710
  * @returns {string} The operation's group id.
1817
3711
  */
1818
- beginOperation(message, { id } = {}) {
1819
- if (this.operationGroup) {
1820
- this.operationGroup.depth += 1;
1821
- return this.operationGroup.id;
3712
+ beginOperation(message, { id, kind, ref, keys, minted } = {}) {
3713
+ const frame = {
3714
+ keys: keys || null,
3715
+ count: 0,
3716
+ // Any iterable of ids (a Set, an array).
3717
+ minted: minted ? new Set(minted) : null,
3718
+ depth: 1,
3719
+ };
3720
+ const open = this.operationGroup;
3721
+ this._openedFrame = null;
3722
+ if (open) {
3723
+ // A nested operation joins the outer one. When it brings its own key
3724
+ // seed or ids (a queued edit run inside a longer operation), it numbers
3725
+ // the keys in a frame of its own until it ends, then the frame under it
3726
+ // numbers again.
3727
+ open.depth += 1;
3728
+ if (keys || minted) {
3729
+ frame.depth = open.depth;
3730
+ open.frames.push(frame);
3731
+ this._openedFrame = frame;
3732
+ }
3733
+ return open.id;
1822
3734
  }
1823
3735
  this.operationGroup = {
1824
3736
  id: id || crypto.randomUUID(),
1825
3737
  message: message == null ? null : String(message),
3738
+ kind: kind == null ? null : String(kind),
3739
+ ref: ref == null ? null : String(ref),
1826
3740
  depth: 1,
1827
3741
  written: false,
1828
3742
  refined: undefined,
3743
+ frames: [frame],
1829
3744
  };
3745
+ this._openedFrame = frame;
1830
3746
  return this.operationGroup.id;
1831
3747
  }
1832
3748
 
3749
+ /**
3750
+ * A seed for the Idempotency-Keys of a logical operation that may be run
3751
+ * again from the top (see beginOperation's `keys`). Keep it with the work
3752
+ * it belongs to and pass it to every run.
3753
+ * @returns {{seed: string, stamps: Map<number, number|null>}}
3754
+ */
3755
+ keySeed() {
3756
+ return { seed: uuidv7(), stamps: new Map() };
3757
+ }
3758
+
1833
3759
  /**
1834
3760
  * End the current logical operation. With no argument this is purely local
1835
3761
  * (no request). Pass a refined `message` to relabel the group now that the
@@ -1843,6 +3769,12 @@ class PlaidClient {
1843
3769
  const group = this.operationGroup;
1844
3770
  if (!group) return;
1845
3771
  if (group.depth > 1) {
3772
+ // A frame begun at this depth by hand (beginOperation) ends here. One
3773
+ // begun by withOperation is ended by that call itself (_dropFrame).
3774
+ const top = group.frames.at(-1);
3775
+ if (group.frames.length > 1 && !top.owned && top.depth === group.depth) {
3776
+ group.frames.pop();
3777
+ }
1846
3778
  group.depth -= 1;
1847
3779
  return;
1848
3780
  }
@@ -1865,157 +3797,229 @@ class PlaidClient {
1865
3797
  * callback to refine the label once the outcome is known.
1866
3798
  *
1867
3799
  * await client.withOperation('Merge morphemes', async (setMessage) => {
1868
- * await client.batched(() => { ... });
3800
+ * await client.batched(async (b) => { ... });
1869
3801
  * setMessage(`Merged ${n} morphemes`);
1870
3802
  * });
1871
3803
  *
3804
+ * The kind and reference (see beginOperation) come after `fn`:
3805
+ *
3806
+ * await client.withOperation('Import ELAN corpus', run, { kind: 'import', ref: 'format:elan' });
3807
+ *
1872
3808
  * @param {string} message - Human label for the operation.
1873
3809
  * @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
3810
+ * @param {object} [opts] - Optional `{ kind, ref, id, keys }`, as for beginOperation.
1874
3811
  * @returns {Promise<any>} Whatever `fn` resolves to.
1875
3812
  */
1876
- async withOperation(message, fn) {
1877
- this.beginOperation(message);
3813
+ async withOperation(message, fn, { kind, ref, id, keys, minted } = {}) {
3814
+ this.beginOperation(message, { kind, ref, id, keys, minted });
1878
3815
  const group = this.operationGroup;
1879
- const setMessage = (msg) => { if (group.depth === 1) group.refined = msg; };
3816
+ // The key frame this call opened is ended by this call, whatever else
3817
+ // began or ended meanwhile, not by whichever end comes at its depth.
3818
+ const frame = this._openedFrame;
3819
+ if (frame) frame.owned = true;
3820
+ const setMessage = (msg) => {
3821
+ if (group.depth === 1) group.refined = msg;
3822
+ };
3823
+ let result;
1880
3824
  try {
1881
- return await fn(setMessage);
1882
- } finally {
1883
- await this.endOperation();
3825
+ result = await fn(setMessage);
3826
+ } catch (e) {
3827
+ // A relabel the server refuses (a token scoped to projects may not
3828
+ // relabel a group, which names none) must not take the place of the
3829
+ // error `fn` threw: the caller answers for that one.
3830
+ dropFrame(group, frame);
3831
+ try {
3832
+ await this.endOperation();
3833
+ } catch (relabel) {
3834
+ console.warn('The operation was not relabelled:', relabel);
3835
+ }
3836
+ throw e;
1884
3837
  }
3838
+ dropFrame(group, frame);
3839
+ await this.endOperation();
3840
+ return result;
1885
3841
  }
1886
3842
 
1887
- /** Begin a batch of operations. Subsequent API calls will be queued. */
1888
- beginBatch() {
1889
- this.isBatching = true;
1890
- this.batchOperations = [];
1891
- // Strict mode stamps the expected document-version on the FIRST write of
1892
- // the batch only (see _request) — reset the marker per batch.
1893
- this.batchVersionStamped = false;
1894
- }
1895
3843
 
1896
3844
  /**
1897
- * Submit all queued batch operations as a single batch request, executed
1898
- * atomically. If any operation fails, all changes are rolled back.
1899
- * @returns {Promise<Array>} Array of results corresponding to each operation
3845
+ * Open a batch: a view of this client with the same bundles, on which every
3846
+ * write of project data queues instead of going out. `submit()` sends the
3847
+ * queued operations as ONE atomic request (larger than the server's cap,
3848
+ * as consecutive requests with the results concatenated in queue order,
3849
+ * each atomic on its own, so a failure in a later one leaves the earlier
3850
+ * ones committed, and the error it throws carries `committed`, the count
3851
+ * saved, and `committedResults`, their results) and
3852
+ * resolves to one result per operation; `abort()` drops them. A call made
3853
+ * on the client itself is never touched by an open batch, and a read or an
3854
+ * out-of-band signal made on the batch goes over the wire now (see the note
3855
+ * at the top of `http.js`).
3856
+ *
3857
+ * const b = client.batch();
3858
+ * b.tokens.bulkCreate(sentenceOps);
3859
+ * b.tokens.bulkCreate(wordOps);
3860
+ * const [sentRes, wordRes] = await b.submit();
3861
+ *
3862
+ * Server-side a batch runs sequentially in one transaction: a child op sees
3863
+ * parents created earlier in the same batch, and any op's failure rolls the
3864
+ * whole batch back. A batch is not nestable. Prefer `batched()`, which
3865
+ * submits or aborts for you.
3866
+ * @returns {PlaidClient} the batch view
1900
3867
  */
1901
- async submitBatch() {
1902
- if (!this.isBatching) {
1903
- throw new Error('No active batch. Call beginBatch() first.');
1904
- }
3868
+ batch() {
3869
+ return openBatch(this);
3870
+ }
1905
3871
 
1906
- if (this.batchOperations.length === 0) {
1907
- this.isBatching = false;
1908
- return [];
1909
- }
3872
+ /**
3873
+ * POST one batch request (at most MAX_BATCH_OPS operations). Resolves to
3874
+ * one `{ status, headers, body }` per operation, with only the body
3875
+ * recased: the headers keep the server's spelling. `key` is the request's
3876
+ * Idempotency-Key: a batch whose answer is lost (no response, 502, 504) is
3877
+ * sent again under it, and answered from the first send if that landed.
3878
+ */
3879
+ async _postBatch(url, body, { key = uuidv7() } = {}) {
3880
+ const fetchOptions = {
3881
+ method: "POST",
3882
+ headers: {
3883
+ Authorization: `Bearer ${this.token}`,
3884
+ "Content-Type": "application/json",
3885
+ [IDEMPOTENCY_HEADER]: key,
3886
+ },
3887
+ body: JSON.stringify(body),
3888
+ };
1910
3889
 
1911
3890
  try {
1912
- let url = `${this.baseUrl}/api/v1/batch`;
1913
- const body = this.batchOperations.map(op => ({
1914
- path: op.path,
1915
- method: op.method.toUpperCase(),
1916
- ...(op.body && { body: op.body }),
1917
- }));
1918
-
1919
- const fetchOptions = {
1920
- method: 'POST',
1921
- headers: {
1922
- 'Authorization': `Bearer ${this.token}`,
1923
- 'Content-Type': 'application/json',
1924
- },
1925
- body: JSON.stringify(body),
3891
+ // Retry a 503: the batch is atomic, so a refused one wrote nothing and
3892
+ // repeating it is safe. A fresh timeout signal per attempt — an
3893
+ // AbortSignal.timeout stays aborted once it fires.
3894
+ const attempt = async () => {
3895
+ const signal = timeoutSignal(this.batchTimeout);
3896
+ let res;
3897
+ try {
3898
+ res = await fetch(
3899
+ url,
3900
+ signal ? { ...fetchOptions, signal } : fetchOptions,
3901
+ );
3902
+ } catch (e) {
3903
+ throw makeNetworkError(e, url, "POST");
3904
+ }
3905
+ if (!res.ok) {
3906
+ throw makeHttpError(res, await parseErrorBody(res), url, "POST");
3907
+ }
3908
+ return res;
1926
3909
  };
1927
- const signal = timeoutSignal(this.timeout);
1928
- if (signal) fetchOptions.signal = signal;
1929
-
1930
- try {
1931
- const response = await fetch(url, fetchOptions);
1932
- if (!response.ok) {
1933
- throw makeHttpError(response, await parseErrorBody(response), url, 'POST');
3910
+ const onRetry = (info) => this._noteRetry(info);
3911
+ const response = await retryUnknown(() => retryWhileBusy(attempt, { onRetry }), {
3912
+ delaysMs: this.retryDelaysMs,
3913
+ onRetry,
3914
+ }).catch((error) => {
3915
+ error.idempotencyKey = key;
3916
+ // 401 means the token is missing, expired or invalid: fire the app's
3917
+ // auth-error handler once, exactly as makeRequest does.
3918
+ if (
3919
+ error?.status === 401 &&
3920
+ typeof this.onAuthError === "function" &&
3921
+ !this._authErrorFired
3922
+ ) {
3923
+ this._authErrorFired = true;
3924
+ try {
3925
+ this.onAuthError(error);
3926
+ } catch (_) {
3927
+ /* handler must not mask the original error */
3928
+ }
1934
3929
  }
3930
+ throw error;
3931
+ });
3932
+
3933
+ const results = await response.json();
3934
+ // A replayed batch carries the versions right after its first send.
3935
+ const replayed = response.headers?.get?.(REPLAYED_HEADER) === "true";
1935
3936
 
1936
- const results = await response.json();
1937
-
1938
- // Extract document versions from each batch response
1939
- for (const result of results) {
1940
- if (result.headers && result.headers['X-Document-Versions']) {
1941
- try {
1942
- const versionsMap = JSON.parse(result.headers['X-Document-Versions']);
1943
- if (typeof versionsMap === 'object' && versionsMap !== null) {
1944
- // Clone once per response, then merge — not once per entry.
1945
- this.documentVersions = { ...this.documentVersions, ...versionsMap };
1946
- }
1947
- } catch (e) {
1948
- console.warn('Failed to parse document versions header from batch response:', e);
3937
+ // Extract document versions from each batch response
3938
+ for (const result of results) {
3939
+ if (result.headers && result.headers["X-Document-Versions"]) {
3940
+ try {
3941
+ const versionsMap = JSON.parse(
3942
+ result.headers["X-Document-Versions"],
3943
+ );
3944
+ if (typeof versionsMap === "object" && versionsMap !== null) {
3945
+ // Clone once per response, then merge — not once per entry.
3946
+ this.documentVersions = mergeVersions(
3947
+ this.documentVersions,
3948
+ versionsMap,
3949
+ replayed,
3950
+ );
1949
3951
  }
3952
+ } catch (e) {
3953
+ console.warn(
3954
+ "Failed to parse document versions header from batch response:",
3955
+ e,
3956
+ );
1950
3957
  }
1951
3958
  }
1952
-
1953
- return results.map(result => transformResponse(result));
1954
- } catch (error) {
1955
- if (error.status) throw error;
1956
- throw makeNetworkError(error, url, 'POST');
1957
3959
  }
1958
- } finally {
1959
- this.isBatching = false;
1960
- this.batchOperations = [];
1961
- }
1962
- }
1963
3960
 
1964
- /** Abort the current batch without executing any operations. */
1965
- abortBatch() {
1966
- this.isBatching = false;
1967
- this.batchOperations = [];
1968
- }
1969
-
1970
- /**
1971
- * Check if currently in batch mode.
1972
- * @returns {boolean}
1973
- */
1974
- isBatchMode() {
1975
- return this.isBatching;
3961
+ const out = results.map((result) => ({
3962
+ ...result,
3963
+ body: transformResponse(result.body),
3964
+ }));
3965
+ return replayed ? markReplayed(out) : out;
3966
+ } catch (error) {
3967
+ if (error.status !== undefined) throw error;
3968
+ throw Object.assign(makeNetworkError(error, url, "POST"), {
3969
+ idempotencyKey: key,
3970
+ });
3971
+ }
1976
3972
  }
1977
3973
 
1978
3974
  /**
1979
- * Run `fn` with a batch open, then submit all queued ops as ONE atomic
1980
- * request — or abort the batch if `fn` throws, so a half-open batch can never
1981
- * silently swallow later non-batch calls. `fn` makes the (queued) client
1982
- * calls; it must NOT call submitBatch itself. Resolves to the batch results
1983
- * array (`[]` if `fn` queued nothing).
3975
+ * Run `fn` with a batch, then submit all queued ops as `submit()` does (ONE
3976
+ * atomic request up to MAX_BATCH_OPS operations, consecutive requests past
3977
+ * it), or abort the batch if `fn` throws. `fn` receives the batch and makes its
3978
+ * writes on it; it must NOT call `submit()` itself. Resolves to the batch
3979
+ * results array (`[]` if `fn` queued nothing).
1984
3980
  *
1985
- * const [sentRes, wordRes] = await client.batched(async () => {
1986
- * client.tokens.bulkCreate(sentenceOps);
1987
- * client.tokens.bulkCreate(wordOps);
3981
+ * const [sentRes, wordRes] = await client.batched(async (b) => {
3982
+ * b.tokens.bulkCreate(sentenceOps);
3983
+ * b.tokens.bulkCreate(wordOps);
1988
3984
  * });
1989
3985
  *
1990
- * Server-side a batch runs sequentially in one transaction (a child op sees
1991
- * parents created earlier in the same `fn`; any op's failure rolls the whole
1992
- * batch back). Not nestable. Named `batched()` because `client.batch` is the
1993
- * low-level batch resource.
1994
- * @param {() => (void | Promise<void>)} fn
3986
+ * A write made on `client` inside `fn` is not part of the batch: it goes
3987
+ * over the wire at once, as it would anywhere else. See `batch()`.
3988
+ * @param {(batch: PlaidClient) => (void | Promise<void>)} fn
1995
3989
  * @returns {Promise<Array>}
1996
3990
  */
1997
3991
  async batched(fn) {
1998
- this.beginBatch();
3992
+ const batch = this.batch();
1999
3993
  try {
2000
- await fn();
3994
+ await fn(batch);
2001
3995
  } catch (e) {
2002
- // fn failed: drop the queued ops so the client leaves batch mode and
2003
- // later plain calls don't queue into a never-submitted batch.
2004
- if (this.isBatchMode()) this.abortBatch();
3996
+ batch.abort();
2005
3997
  throw e;
2006
3998
  }
2007
- return this.submitBatch();
3999
+ return batch.submit();
2008
4000
  }
2009
4001
 
2010
4002
  /**
2011
- * Authenticate and return a new client instance with token. This is the
2012
- * single auth entry point — there is no `client.login` resource.
2013
- * @param {string} baseUrl - The base URL for the API
2014
- * @param {string} userId - User ID for authentication
2015
- * @param {string} password - Password for authentication
2016
- * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
2017
- * @returns {Promise<PlaidClient>} - Authenticated client instance
4003
+ * Liveness, version and database size, with NO authentication and no client
4004
+ * instance — for a launcher or status page checking whether a server is up
4005
+ * before anyone logs in.
4006
+ * @param {string} baseUrl - The API base URL
4007
+ * @param {object} [options]
2018
4008
  */
4009
+ static async health(baseUrl, options = {}) {
4010
+ return anonymousGet(baseUrl, "/health", options);
4011
+ }
4012
+
4013
+ /**
4014
+ * The limits this server enforces, with NO authentication and no client
4015
+ * instance. See the instance method for the shape.
4016
+ * @param {string} baseUrl - The API base URL
4017
+ * @param {object} [options]
4018
+ */
4019
+ static async info(baseUrl, options = {}) {
4020
+ return anonymousGet(baseUrl, "/api/v1/info", options);
4021
+ }
4022
+
2019
4023
  /**
2020
4024
  * Build the link to hand someone for an invite code. The server never sees
2021
4025
  * an app URL, so the app that minted the invite is the one that names it.
@@ -2026,88 +4030,229 @@ class PlaidClient {
2026
4030
  * @returns {string}
2027
4031
  */
2028
4032
  static inviteUrl(appUrl, code) {
2029
- return `${appUrl.replace(/#.*$/, '').replace(/\/$/, '')}/#/invite/${encodeURIComponent(code)}`;
4033
+ return `${appUrl.replace(/#.*$/, "").replace(/\/$/, "")}/#/invite/${encodeURIComponent(code)}`;
2030
4034
  }
2031
4035
 
2032
4036
  /**
2033
4037
  * Describe an invite code, with NO authentication — this is what a signup
2034
4038
  * page calls before the redeemer has an account. Returns the kind of link
2035
4039
  * ("signup" or "password-reset"), its status ("active", "used", "expired",
2036
- * "revoked"), and the project it grants access to, if any.
4040
+ * "revoked", or "inactive" when its creator can no longer grant what it
4041
+ * grants), and the project it grants access to, if any.
2037
4042
  *
2038
4043
  * Throws a 404-shaped error if the code is unknown. A known-but-dead code
2039
4044
  * resolves normally with a non-"active" status, so the page can say why.
2040
4045
  * @param {string} baseUrl - The API base URL
2041
4046
  * @param {string} code - The invite code
2042
4047
  * @param {object} [options]
2043
- * @returns {Promise<{kind: string, status: string, expiresAt: string, projectName?: string, projectRole?: string, username?: string, grantAdmin: boolean}>}
4048
+ * @returns {Promise<{kind: string, status: string, expiresAt: string, projectName?: string, projectRole?: string, email?: string, grantAdmin: boolean}>}
2044
4049
  */
2045
4050
  static async lookupInvite(baseUrl, code, options = {}) {
2046
- return anonymousPost(baseUrl, '/api/v1/invite-codes/lookup', { code }, options);
4051
+ return anonymousPost(baseUrl, "/api/v1/invites/lookup", { code }, options);
2047
4052
  }
2048
4053
 
2049
4054
  /**
2050
4055
  * Redeem an invite code, with NO authentication.
2051
4056
  *
2052
- * For a signup invite, pass `username` and `password` to create the account;
2053
- * the invite's grants are applied in the same transaction. For a password
2054
- * reset link, pass `password` only. Resolves to a logged-in client, exactly
2055
- * like login() — the redeemer just chose these credentials, so there is no
2056
- * reason to send them to a login form to retype them.
4057
+ * For a signup invite, pass `email` and `password` to create the account
4058
+ * (and optionally `displayName`); the invite's grants are applied in the
4059
+ * same transaction. For a password reset link, pass `password` only.
4060
+ * Resolves to a logged-in client, exactly like login() — the redeemer just
4061
+ * chose these credentials, so there is no reason to send them to a login
4062
+ * form to retype them.
2057
4063
  * @param {string} baseUrl - The API base URL
2058
4064
  * @param {string} code - The invite code
2059
4065
  * @param {object} credentials
2060
- * @param {string} [credentials.username] - Desired username (signup only)
4066
+ * @param {string} [credentials.email] - The new account's email address,
4067
+ * which becomes its id and login (signup only)
4068
+ * @param {string} [credentials.displayName] - How the new user is shown in
4069
+ * the UI; defaults to the local part of the email (signup only)
2061
4070
  * @param {string} credentials.password - Desired password (min 8 characters)
2062
4071
  * @param {object} [options]
2063
4072
  * @returns {Promise<{client: PlaidClient, userId: string, kind: string}>}
2064
4073
  */
2065
- static async redeemInvite(baseUrl, code, { username, password } = {}, options = {}) {
4074
+ static async redeemInvite(
4075
+ baseUrl,
4076
+ code,
4077
+ { email, password, displayName } = {},
4078
+ options = {},
4079
+ ) {
2066
4080
  const data = await anonymousPost(
2067
- baseUrl, '/api/v1/invite-codes/redeem',
2068
- bodyOf({ code, username, password }), options,
4081
+ baseUrl,
4082
+ "/api/v1/invites/redeem",
4083
+ bodyOf({ code, email, password, "display-name": displayName }),
4084
+ options,
2069
4085
  );
2070
4086
  return {
2071
- client: new PlaidClient(baseUrl.replace(/\/$/, ''), data.token || '', options),
4087
+ client: new PlaidClient(
4088
+ baseUrl.replace(/\/$/, ""),
4089
+ data.token || "",
4090
+ options,
4091
+ ),
2072
4092
  userId: data.userId,
2073
4093
  kind: data.kind,
2074
4094
  };
2075
4095
  }
2076
4096
 
4097
+ /**
4098
+ * Authenticate and return a new client instance with token. This is the
4099
+ * single auth entry point — there is no `client.login` resource.
4100
+ * @param {string} baseUrl - The base URL for the API
4101
+ * @param {string} userId - User ID for authentication
4102
+ * @param {string} password - Password for authentication
4103
+ * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
4104
+ * @returns {Promise<PlaidClient>} - Authenticated client instance
4105
+ */
2077
4106
  static async login(baseUrl, userId, password, options = {}) {
2078
- baseUrl = baseUrl.replace(/\/$/, '');
4107
+ baseUrl = baseUrl.replace(/\/$/, "");
2079
4108
  const url = `${baseUrl}/api/v1/login`;
2080
4109
  try {
2081
4110
  const fetchOptions = {
2082
- method: 'POST',
2083
- headers: { 'Content-Type': 'application/json' },
2084
- body: JSON.stringify({ 'user-id': userId, password }),
4111
+ method: "POST",
4112
+ headers: { "Content-Type": "application/json" },
4113
+ body: JSON.stringify({ "user-id": userId, password }),
2085
4114
  };
2086
- const signal = timeoutSignal(options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS);
4115
+ const signal = timeoutSignal(
4116
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS,
4117
+ );
2087
4118
  if (signal) fetchOptions.signal = signal;
2088
4119
 
2089
4120
  const response = await fetch(url, fetchOptions);
2090
4121
  if (!response.ok) {
2091
- throw makeHttpError(response, await parseErrorBody(response), url, 'POST');
4122
+ throw makeHttpError(
4123
+ response,
4124
+ await parseErrorBody(response),
4125
+ url,
4126
+ "POST",
4127
+ );
2092
4128
  }
2093
4129
 
2094
4130
  const data = await response.json();
2095
- const token = data.token || '';
4131
+ const token = data.token || "";
2096
4132
  return new PlaidClient(baseUrl, token, options);
2097
4133
  } catch (error) {
2098
4134
  if (error.status) throw error;
2099
- throw makeNetworkError(error, url, 'POST');
4135
+ throw makeNetworkError(error, url, "POST");
2100
4136
  }
2101
4137
  }
2102
4138
  }
2103
4139
 
4140
+
4141
+ /**
4142
+ * The request options of a config write. With `options.expected` present
4143
+ * (even as undefined, which means the key was absent) the write is a
4144
+ * compare-and-set: `?if-unchanged=true` and the body `{expected, value}`,
4145
+ * which the server refuses with a 409 when the stored value is no longer
4146
+ * `expected`. Without it the body is the value itself. Config is opaque, so
4147
+ * neither body is re-cased.
4148
+ */
4149
+ function configRequest(auditMessage, value, options, isDelete = false) {
4150
+ if (!options || !Object.hasOwn(options, "expected")) {
4151
+ return isDelete
4152
+ ? { auditMessage, skipResponseTransform: true }
4153
+ : { auditMessage, rawBody: value, skipResponseTransform: true };
4154
+ }
4155
+ const expected = options.expected === undefined ? null : options.expected;
4156
+ return {
4157
+ auditMessage,
4158
+ queryParams: { "if-unchanged": true },
4159
+ rawBody: isDelete ? { expected } : { expected, value },
4160
+ skipResponseTransform: true,
4161
+ };
4162
+ }
4163
+
4164
+ /**
4165
+ * The four layer-constraint methods of a layer bundle, on `/api/v1/<kind>`.
4166
+ * See constraints.js and the core manual, "Layer constraints".
4167
+ */
4168
+ function constraintMethods(client, kind) {
4169
+ const base = (layerId) => `/api/v1/${kind}/${layerId}/constraints`;
4170
+ return {
4171
+ /**
4172
+ * Declare an app namespace's constraints on this layer, replacing that
4173
+ * namespace's list. Refused with 422 and the violations when the layer's
4174
+ * stored data breaks them (see violationsOf).
4175
+ * @param {string} layerId - The layer ID
4176
+ * @param {string} namespace - The declaring app's namespace, e.g. "igt"
4177
+ * @param {Array<object>} constraints - e.g. [{type: "single-span"}, {type: "value-set", values: ["N"]}]
4178
+ * @param {string} [auditMessage] - Audit message for this write
4179
+ * @param {{expected?: Array<object>|null}} [options] - `expected`: the list read for this
4180
+ * namespace (null when it was absent). The write is then refused with a 409 when the stored
4181
+ * list differs.
4182
+ * @returns {Promise<{constraints: object}>} the layer's whole constraint map
4183
+ */
4184
+ setConstraints: (layerId, namespace, constraints, auditMessage, options) =>
4185
+ client._request("PUT", `${base(layerId)}/${namespace}`, {
4186
+ auditMessage,
4187
+ body: constraintsBody(constraints, options),
4188
+ }),
4189
+ /**
4190
+ * Remove an app namespace's constraints from this layer.
4191
+ * @param {string} layerId - The layer ID
4192
+ * @param {string} namespace - The declaring app's namespace
4193
+ * @param {string} [auditMessage] - Audit message for this write
4194
+ * @param {{expected?: Array<object>|null}} [options] - `expected`, as on setConstraints.
4195
+ */
4196
+ deleteConstraints: (layerId, namespace, auditMessage, options) => {
4197
+ const body = constraintsBody(undefined, options);
4198
+ return client._request("DELETE", `${base(layerId)}/${namespace}`, {
4199
+ auditMessage,
4200
+ ...(Object.keys(body).length ? { body } : {}),
4201
+ });
4202
+ },
4203
+ /**
4204
+ * The violations the given constraints would meet in this layer's stored
4205
+ * data. Writes nothing.
4206
+ * @param {string} layerId - The layer ID
4207
+ * @param {Array<object>} constraints - The list to check
4208
+ * @returns {Promise<{violations: Array<object>, violationCount: number}>}
4209
+ */
4210
+ checkConstraints: (layerId, constraints) =>
4211
+ client._request("POST", `${base(layerId)}/check`, { body: { constraints } }),
4212
+ /**
4213
+ * Apply the remedies of the given constraints' remediable types
4214
+ * (coextensive, single-span, single-link, same-ancestor) to every
4215
+ * violation in this layer's stored data, one operation per document.
4216
+ * With `document`, only that document is repaired, and a writer may
4217
+ * ask. A document another user holds the lock on is left as it is and
4218
+ * listed under `locked`.
4219
+ * @param {string} layerId - The layer ID
4220
+ * @param {Array<object>} constraints - The list to repair for
4221
+ * @param {string} [auditMessage] - Audit message for this write
4222
+ * @param {{document?: string}} [options]
4223
+ * @returns {Promise<{repaired: Array<object>, locked: Array<{document: string, lockedBy: string}>, violations: Array<object>, violationCount: number}>}
4224
+ */
4225
+ repairConstraints: (layerId, constraints, auditMessage, options) =>
4226
+ client._request("POST", `${base(layerId)}/repair`, {
4227
+ auditMessage,
4228
+ body: options?.document ? { constraints, document: options.document } : { constraints },
4229
+ }),
4230
+ };
4231
+ }
4232
+
2104
4233
  export default PlaidClient;
2105
4234
  export { PlaidClient };
4235
+ export { valueSetAllows, violationsOf } from "./constraints.js";
2106
4236
 
2107
4237
  // Unicode code-point helpers for text offsets (token begin/end are code-point
2108
4238
  // indices). See ./codepoint.js.
2109
- export { cpLength, cpSlice, cpSlicer, utf16ToCp, cpToUtf16, cpIndexOf } from './codepoint.js';
2110
- export { PLAID_NAMESPACE, ROLE_KEY, ROLES, readRole, findByRole } from './roles.js';
4239
+ export {
4240
+ cpLength,
4241
+ cpSlice,
4242
+ cpSlicer,
4243
+ utf16ToCp,
4244
+ cpIndexOf,
4245
+ } from "./codepoint.js";
4246
+ export { composeTextEdits, gapsToOps, applyTextOps } from "./textEdits.js";
4247
+ export {
4248
+ PLAID_NAMESPACE,
4249
+ ROLE_KEY,
4250
+ PRESERVE_ON_SPLIT_KEY,
4251
+ SPLIT_ON_SPACE_KEY,
4252
+ ROLES,
4253
+ readRole,
4254
+ findByRole,
4255
+ } from "./roles.js";
2111
4256
  // Service self-description helpers: filter discovered services by task, read a
2112
4257
  // service's parameter schema/summary, and build/coerce form values. See
2113
4258
  // ./serviceSchema.js.
@@ -2119,20 +4264,57 @@ export {
2119
4264
  getServiceSummary,
2120
4265
  buildDefaultValues,
2121
4266
  coerceParamValues,
2122
- } from './serviceSchema.js';
2123
- // Provenance: the cross-app convention for machine-provided vs human-labeled
2124
- // information (flat prov/provSource/provConfirmed metadata; absence = human),
2125
- // plus the machine-writer contract. See ./provenance.js and the manual,
2126
- // "Provenance".
4267
+ } from "./serviceSchema.js";
4268
+ // Cooperative cancellation for a service AUTHORED in JS: `progress()` is a
4269
+ // checkpoint that throws ServiceCancelled once the requester stops the
4270
+ // request, and `critical()` holds that off around writes. See ./services.js
4271
+ // and the manual, "Stopping a request".
4272
+ export { ServiceCancelled, createCancelScope } from "./services.js";
4273
+ // Who asked for a service run, named in its History label and in what it
4274
+ // stores. See ./requester.js.
4275
+ export { REQUESTED_BY, makeRequester, requesterOf } from "./requester.js";
4276
+ // Document locks: `client.documents.locked()` holds one for a block of work and
4277
+ // renews it while the block runs. DocumentLockLost is what a write throws once
4278
+ // a renewal has failed. See ./documentLock.js.
4279
+ export { DocumentLock, DocumentLockLost } from "./documentLock.js";
4280
+ // Provenance: the cross-app convention for who made an annotation (flat
4281
+ // prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
4282
+ // a machine; 'contributed' = a person whose work is reviewed), plus the
4283
+ // machine-writer contract. See ./provenance.js and the manual, "Provenance".
2127
4284
  export {
2128
4285
  PROV,
2129
4286
  PROV_STATES,
2130
- PROV_CONFIRMED,
4287
+ PROVENANCE_KEYS,
2131
4288
  stampInferred,
2132
4289
  confirmedInferred,
4290
+ stampContributed,
2133
4291
  provState,
4292
+ provOrigin,
2134
4293
  isMachine,
2135
4294
  isProtected,
2136
- verifyOnEdit,
4295
+ needsReview,
4296
+ contributeOnEdit,
2137
4297
  serviceSource,
2138
- } from './provenance.js';
4298
+ REVIEW_KEY,
4299
+ readReview,
4300
+ projectRole,
4301
+ isReviewed,
4302
+ withReviewedUser,
4303
+ writerPolicy,
4304
+ } from "./provenance.js";
4305
+ // Metadata ops: the body of a metadata PATCH and of a bulk update entry's
4306
+ // `metadata`. See ./metadataOps.js.
4307
+ export {
4308
+ metadataOps,
4309
+ applyMetadataOps,
4310
+ mergeMetadata,
4311
+ isReservedMetadataKey,
4312
+ } from "./metadataOps.js";
4313
+ // The ids a create or bulk create answered with, off the call or its batch
4314
+ // result. See ./created.js.
4315
+ export { createdId, createdIds } from "./created.js";
4316
+ // UUIDv7 ids for what a client creates. See ./ids.js and the manual,
4317
+ // "Retrying a write".
4318
+ export { uuidv7 } from "./ids.js";
4319
+ export { wasReplayed } from "./replayed.js";
4320
+ export { MAX_BATCH_OPS };