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

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