@larc-iu/plaid-client 0.2.0-alpha.18 → 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
@@ -13,13 +13,22 @@ import {
13
13
  makeNetworkError,
14
14
  timeoutSignal,
15
15
  retryWhileBusy,
16
+ retryUnknown,
17
+ nextIdempotencyKey,
18
+ mergeVersions,
19
+ IDEMPOTENCY_HEADER,
20
+ REPLAYED_HEADER,
16
21
  DEFAULT_TIMEOUT_MS,
17
22
  DEFAULT_BATCH_TIMEOUT_MS,
18
23
  } from "./http.js";
24
+ import { uuidv7 } from "./ids.js";
25
+ import { constraintsBody } from "./constraints.js";
19
26
  import { listAll, listPage, iterPages } from "./pagination.js";
20
27
  import { recorderFor } from "./events.js";
21
28
  import { withDocumentLock } from "./documentLock.js";
22
29
  import { createSSEConnection } from "./sse.js";
30
+ import { makeBatchRef, rebaseRefs } from "./batchRef.js";
31
+ import { markReplayed, wasReplayed } from "./replayed.js";
23
32
  import {
24
33
  discoverServices,
25
34
  discardService,
@@ -47,6 +56,10 @@ function opTypesParam(opTypes) {
47
56
  return joined.length > 0 ? joined : undefined;
48
57
  }
49
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
+
50
63
  // Helper: build body object, filtering out undefined values
51
64
  function bodyOf(obj) {
52
65
  const result = {};
@@ -131,8 +144,10 @@ function openBatch(client) {
131
144
  batch.client = client;
132
145
  batch.operations = [];
133
146
  // The document each queued op's strict-mode stamp is for (null for none),
134
- // index for index with `operations`.
147
+ // and the logical operation it joined (null for none), index for index with
148
+ // `operations`.
135
149
  batch.stampedDocuments = [];
150
+ batch.stampedGroups = [];
136
151
  batch.open = true;
137
152
  batch._request = (method, path, options = {}) =>
138
153
  queueRequest(batch, method, path, options);
@@ -140,10 +155,14 @@ function openBatch(client) {
140
155
  throw new Error("A batch is not nestable: queue on the batch you have");
141
156
  };
142
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);
143
161
  batch.submit = () => submitBatch(batch);
144
162
  batch.abort = () => {
145
163
  batch.operations = [];
146
164
  batch.stampedDocuments = [];
165
+ batch.stampedGroups = [];
147
166
  batch.open = false;
148
167
  };
149
168
  batch._installResources();
@@ -157,8 +176,10 @@ async function submitBatch(batch) {
157
176
  batch.open = false;
158
177
  const ops = batch.operations;
159
178
  const stamps = batch.stampedDocuments;
179
+ const groups = batch.stampedGroups;
160
180
  batch.operations = [];
161
181
  batch.stampedDocuments = [];
182
+ batch.stampedGroups = [];
162
183
  if (ops.length === 0) return [];
163
184
  const client = batch.client;
164
185
  const url = `${client.baseUrl}/api/v1/batch`;
@@ -176,9 +197,14 @@ async function submitBatch(batch) {
176
197
  // were saved) and `committedResults` (their results, in queue order), so a
177
198
  // caller can tell a partial write from a batch that saved nothing. The
178
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`.
179
205
  const results = [];
180
206
  try {
181
- await sendChunks(client, url, ops, stamps, results);
207
+ await sendChunks(client, url, ops, stamps, results, groups);
182
208
  } catch (error) {
183
209
  if (error && typeof error === "object") {
184
210
  error.committed = results.length;
@@ -189,24 +215,71 @@ async function submitBatch(batch) {
189
215
  return results;
190
216
  }
191
217
 
192
- async function sendChunks(client, url, ops, stamps, results) {
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 = [];
193
228
  for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
194
- let chunk = ops.slice(i, i + MAX_BATCH_OPS);
195
- if (i > 0) {
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) {
196
251
  chunk = chunk.map((op, j) => {
197
252
  const docId = stamps[i + j];
198
253
  if (!docId) return op;
199
- const path = restampDocumentVersion(
200
- op.path,
201
- client.documentVersions[docId],
202
- );
254
+ const path = restampDocumentVersion(op.path, claim(docId));
203
255
  return { ...op, path };
204
256
  });
205
257
  }
206
- results.push(...(await client._postBatch(url, chunk)));
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
+ }
207
269
  }
208
270
  }
209
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
+
210
283
  /** The query of an events read: `types` as the wire's comma-separated list. */
211
284
  function eventQuery(types, startTime, endTime) {
212
285
  return {
@@ -241,6 +314,9 @@ class PlaidClient {
241
314
  : options.timeout !== undefined
242
315
  ? options.timeout
243
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;
244
320
  this.documentVersions = {};
245
321
  // The server's clock minus this machine's, in ms, from the last response
246
322
  // with a Date header (null before one). See serverNow().
@@ -253,7 +329,8 @@ class PlaidClient {
253
329
  // The open logical operation (audit-log group), or null. While set, every
254
330
  // write is stamped with `?group-id=` (+ `group-message`) so the audit log
255
331
  // folds them into ONE expandable entry. See beginOperation / withOperation.
256
- // 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.
257
334
  this.operationGroup = null;
258
335
  // Optional callback fired once (per client) when any request returns HTTP
259
336
  // 401 — i.e. the token is missing/expired/invalid. Apps use it to discard
@@ -289,10 +366,10 @@ class PlaidClient {
289
366
  * @param {Array} tokens - The tokens to link
290
367
  * @param {any} [metadata] - Metadata for the link. Omit to leave unset; pass null to send JSON null.
291
368
  */
292
- create: (vocabItem, tokens, metadata, auditMessage) =>
369
+ create: (vocabItem, tokens, metadata, auditMessage, { id } = {}) =>
293
370
  this._request("POST", "/api/v1/vocab-links", {
294
371
  auditMessage,
295
- body: bodyOf({ "vocab-item": vocabItem, tokens, metadata }),
372
+ body: bodyOf({ id, "vocab-item": vocabItem, tokens, metadata }),
296
373
  }),
297
374
  /**
298
375
  * Create multiple vocab links in a single operation. Entries may
@@ -396,14 +473,17 @@ class PlaidClient {
396
473
  * `['vocab-item/delete', 'vocab-item/restore']`)
397
474
  * @param {string} [itemId] - Only the changes that wrote this one entry,
398
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
399
478
  */
400
- audit: (id, startTime, endTime, opTypes, itemId) =>
479
+ audit: (id, startTime, endTime, opTypes, itemId, kinds) =>
401
480
  listAll(this, `/api/v1/vocab-layers/${id}/audit`, {
402
481
  query: {
403
482
  "start-time": startTime,
404
483
  "end-time": endTime,
405
484
  "op-types": opTypesParam(opTypes),
406
485
  "item-id": itemId,
486
+ kinds: kindsParam(kinds),
407
487
  },
408
488
  }),
409
489
  /**
@@ -421,7 +501,7 @@ class PlaidClient {
421
501
  */
422
502
  auditPage: (
423
503
  id,
424
- { startTime, endTime, opTypes, order, limit, cursor, itemId } = {},
504
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, itemId } = {},
425
505
  ) =>
426
506
  listPage(this, `/api/v1/vocab-layers/${id}/audit`, {
427
507
  limit,
@@ -430,6 +510,7 @@ class PlaidClient {
430
510
  "start-time": startTime,
431
511
  "end-time": endTime,
432
512
  "op-types": opTypesParam(opTypes),
513
+ kinds: kindsParam(kinds),
433
514
  order,
434
515
  "item-id": itemId,
435
516
  },
@@ -488,24 +569,29 @@ class PlaidClient {
488
569
  * @param {string} namespace - The config namespace
489
570
  * @param {string} configKey - The config key
490
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.
491
575
  */
492
- setConfig: (id, namespace, configKey, configValue, auditMessage) =>
576
+ setConfig: (id, namespace, configKey, configValue, auditMessage, options) =>
493
577
  this._request(
494
578
  "PUT",
495
579
  `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`,
496
- { auditMessage, rawBody: configValue, skipResponseTransform: true },
580
+ configRequest(auditMessage, configValue, options),
497
581
  ),
498
582
  /**
499
583
  * Remove a configuration value for a layer.
500
584
  * @param {string} id - The resource ID
501
585
  * @param {string} namespace - The config namespace
502
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.
503
589
  */
504
- deleteConfig: (id, namespace, configKey, auditMessage) =>
590
+ deleteConfig: (id, namespace, configKey, auditMessage, options) =>
505
591
  this._request(
506
592
  "DELETE",
507
593
  `/api/v1/vocab-layers/${id}/config/${namespace}/${configKey}`,
508
- { auditMessage, skipResponseTransform: true },
594
+ configRequest(auditMessage, undefined, options, true),
509
595
  ),
510
596
  /**
511
597
  * List all vocab layers accessible to user. Transparently follows
@@ -538,10 +624,10 @@ class PlaidClient {
538
624
  * Create a new vocab layer. Note: this also registers the user as a maintainer.
539
625
  * @param {string} name - The name
540
626
  */
541
- create: (name, auditMessage) =>
627
+ create: (name, auditMessage, { id } = {}) =>
542
628
  this._request("POST", "/api/v1/vocab-layers", {
543
629
  auditMessage,
544
- body: bodyOf({ name }),
630
+ body: bodyOf({ id, name }),
545
631
  }),
546
632
  /**
547
633
  * Assign a user as a maintainer for this vocab layer.
@@ -650,10 +736,19 @@ class PlaidClient {
650
736
  * @param {any} value - The value
651
737
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
652
738
  */
653
- create: (layerId, sourceId, targetId, value, metadata, auditMessage) =>
739
+ create: (
740
+ layerId,
741
+ sourceId,
742
+ targetId,
743
+ value,
744
+ metadata,
745
+ auditMessage,
746
+ { id } = {},
747
+ ) =>
654
748
  this._request("POST", "/api/v1/relations", {
655
749
  auditMessage,
656
750
  body: bodyOf({
751
+ id,
657
752
  "layer-id": layerId,
658
753
  "source-id": sourceId,
659
754
  "target-id": targetId,
@@ -696,31 +791,31 @@ class PlaidClient {
696
791
  * @param {string} namespace - The config namespace
697
792
  * @param {string} configKey - The config key
698
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.
699
797
  */
700
- setConfig: (
701
- spanLayerId,
702
- namespace,
703
- configKey,
704
- configValue,
705
- auditMessage,
706
- ) =>
798
+ setConfig: (spanLayerId, namespace, configKey, configValue, auditMessage, options) =>
707
799
  this._request(
708
800
  "PUT",
709
801
  `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`,
710
- { auditMessage, rawBody: configValue, skipResponseTransform: true },
802
+ configRequest(auditMessage, configValue, options),
711
803
  ),
712
804
  /**
713
805
  * Remove a configuration value for a layer.
714
806
  * @param {string} spanLayerId - The span layer ID
715
807
  * @param {string} namespace - The config namespace
716
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.
717
811
  */
718
- deleteConfig: (spanLayerId, namespace, configKey, auditMessage) =>
812
+ deleteConfig: (spanLayerId, namespace, configKey, auditMessage, options) =>
719
813
  this._request(
720
814
  "DELETE",
721
815
  `/api/v1/span-layers/${spanLayerId}/config/${namespace}/${configKey}`,
722
- { auditMessage, skipResponseTransform: true },
816
+ configRequest(auditMessage, undefined, options, true),
723
817
  ),
818
+ ...constraintMethods(this, "span-layers"),
724
819
  /**
725
820
  * Get a span layer by ID.
726
821
  * @param {string} spanLayerId - The span layer ID
@@ -750,10 +845,10 @@ class PlaidClient {
750
845
  * @param {string} tokenLayerId - The token layer ID
751
846
  * @param {string} name - The name
752
847
  */
753
- create: (tokenLayerId, name, auditMessage) =>
848
+ create: (tokenLayerId, name, auditMessage, { id } = {}) =>
754
849
  this._request("POST", "/api/v1/span-layers", {
755
850
  auditMessage,
756
- body: bodyOf({ "token-layer-id": tokenLayerId, name }),
851
+ body: bodyOf({ id, "token-layer-id": tokenLayerId, name }),
757
852
  }),
758
853
  /**
759
854
  * Shift a span layer's display order.
@@ -786,10 +881,11 @@ class PlaidClient {
786
881
  * @param {any} value - The value
787
882
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
788
883
  */
789
- create: (spanLayerId, tokens, value, metadata, auditMessage) =>
884
+ create: (spanLayerId, tokens, value, metadata, auditMessage, { id } = {}) =>
790
885
  this._request("POST", "/api/v1/spans", {
791
886
  auditMessage,
792
887
  body: bodyOf({
888
+ id,
793
889
  "span-layer-id": spanLayerId,
794
890
  tokens,
795
891
  value,
@@ -958,10 +1054,18 @@ class PlaidClient {
958
1054
  * @param {string} body - The request body
959
1055
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
960
1056
  */
961
- create: (textLayerId, documentId, body, metadata, auditMessage) =>
1057
+ create: (
1058
+ textLayerId,
1059
+ documentId,
1060
+ body,
1061
+ metadata,
1062
+ auditMessage,
1063
+ { id } = {},
1064
+ ) =>
962
1065
  this._request("POST", "/api/v1/texts", {
963
1066
  auditMessage,
964
1067
  body: bodyOf({
1068
+ id,
965
1069
  "text-layer-id": textLayerId,
966
1070
  "document-id": documentId,
967
1071
  body,
@@ -989,13 +1093,45 @@ class PlaidClient {
989
1093
  * {type: 'replace', index, length, value: string} — unlike
990
1094
  * delete+insert, a token covering the whole range is resized to keep
991
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).
992
1102
  * @param {string} textId - The text ID
993
1103
  * @param {any} body - The request 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]
994
1129
  */
995
- update: (textId, body, auditMessage) =>
1130
+ edit: (textId, edits, auditMessage, { base, versioned } = {}) =>
996
1131
  this._request("PATCH", `/api/v1/texts/${textId}`, {
997
1132
  auditMessage,
998
- body: bodyOf({ body }),
1133
+ body: bodyOf({ edits, base }),
1134
+ versioned: versioned ?? (base === undefined || base === null),
999
1135
  }),
1000
1136
  };
1001
1137
 
@@ -1065,13 +1201,16 @@ class PlaidClient {
1065
1201
  * types, spelled as in an entry's `op/type` (e.g.
1066
1202
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1067
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
1068
1206
  */
1069
- audit: (userId, startTime, endTime, opTypes) =>
1207
+ audit: (userId, startTime, endTime, opTypes, kinds) =>
1070
1208
  listAll(this, `/api/v1/users/${userId}/audit`, {
1071
1209
  query: {
1072
1210
  "start-time": startTime,
1073
1211
  "end-time": endTime,
1074
1212
  "op-types": opTypesParam(opTypes),
1213
+ kinds: kindsParam(kinds),
1075
1214
  },
1076
1215
  }),
1077
1216
  /**
@@ -1087,7 +1226,7 @@ class PlaidClient {
1087
1226
  */
1088
1227
  auditPage: (
1089
1228
  userId,
1090
- { startTime, endTime, opTypes, order, limit, cursor } = {},
1229
+ { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
1091
1230
  ) =>
1092
1231
  listPage(this, `/api/v1/users/${userId}/audit`, {
1093
1232
  limit,
@@ -1096,6 +1235,7 @@ class PlaidClient {
1096
1235
  "start-time": startTime,
1097
1236
  "end-time": endTime,
1098
1237
  "op-types": opTypesParam(opTypes),
1238
+ kinds: kindsParam(kinds),
1099
1239
  order,
1100
1240
  },
1101
1241
  }),
@@ -1357,6 +1497,8 @@ class PlaidClient {
1357
1497
  this._request("POST", `/api/v1/users/${userId}/tokens`, {
1358
1498
  auditMessage,
1359
1499
  body: bodyOf({ name }),
1500
+ // The answer is the secret, which the server never keeps.
1501
+ noIdempotency: true,
1360
1502
  }),
1361
1503
  /**
1362
1504
  * Revoke a named API token (soft-revoke; idempotent).
@@ -1451,6 +1593,8 @@ class PlaidClient {
1451
1593
  ) =>
1452
1594
  this._request("POST", "/api/v1/invites", {
1453
1595
  auditMessage,
1596
+ // The answer is the code, which the server never keeps.
1597
+ noIdempotency: true,
1454
1598
  body: bodyOf({
1455
1599
  "project-id": projectId,
1456
1600
  "project-role": projectRole,
@@ -1623,13 +1767,16 @@ class PlaidClient {
1623
1767
  * @param {string[]|string} [opts.opTypes] - Only these op types (e.g.
1624
1768
  * `['span-layer/create']`). An entry appears when one of its operations
1625
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
1626
1772
  */
1627
- list: ({ startTime, endTime, opTypes } = {}) =>
1773
+ list: ({ startTime, endTime, opTypes, kinds } = {}) =>
1628
1774
  listAll(this, "/api/v1/audit", {
1629
1775
  query: {
1630
1776
  "start-time": startTime,
1631
1777
  "end-time": endTime,
1632
1778
  "op-types": opTypesParam(opTypes),
1779
+ kinds: kindsParam(kinds),
1633
1780
  },
1634
1781
  }),
1635
1782
  /**
@@ -1641,7 +1788,7 @@ class PlaidClient {
1641
1788
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1642
1789
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1643
1790
  */
1644
- listPage: ({ startTime, endTime, opTypes, order, limit, cursor } = {}) =>
1791
+ listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor } = {}) =>
1645
1792
  listPage(this, "/api/v1/audit", {
1646
1793
  limit,
1647
1794
  cursor,
@@ -1649,6 +1796,7 @@ class PlaidClient {
1649
1796
  "start-time": startTime,
1650
1797
  "end-time": endTime,
1651
1798
  "op-types": opTypesParam(opTypes),
1799
+ kinds: kindsParam(kinds),
1652
1800
  order,
1653
1801
  },
1654
1802
  }),
@@ -1656,13 +1804,14 @@ class PlaidClient {
1656
1804
  * Async-iterate the instance-wide audit log page by page. Admin only.
1657
1805
  * @returns {AsyncGenerator<Array>}
1658
1806
  */
1659
- iterPages: ({ startTime, endTime, opTypes, pageSize } = {}) =>
1807
+ iterPages: ({ startTime, endTime, opTypes, kinds, pageSize } = {}) =>
1660
1808
  iterPages(this, "/api/v1/audit", {
1661
1809
  pageSize,
1662
1810
  query: {
1663
1811
  "start-time": startTime,
1664
1812
  "end-time": endTime,
1665
1813
  "op-types": opTypesParam(opTypes),
1814
+ kinds: kindsParam(kinds),
1666
1815
  },
1667
1816
  }),
1668
1817
  /**
@@ -1722,10 +1871,12 @@ class PlaidClient {
1722
1871
  overlapMode,
1723
1872
  parentTokenLayerId,
1724
1873
  auditMessage,
1874
+ { id } = {},
1725
1875
  ) =>
1726
1876
  this._request("POST", "/api/v1/token-layers", {
1727
1877
  auditMessage,
1728
1878
  body: bodyOf({
1879
+ id,
1729
1880
  "text-layer-id": textLayerId,
1730
1881
  name,
1731
1882
  "overlap-mode": overlapMode,
@@ -1738,31 +1889,31 @@ class PlaidClient {
1738
1889
  * @param {string} namespace - The config namespace
1739
1890
  * @param {string} configKey - The config key
1740
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.
1741
1895
  */
1742
- setConfig: (
1743
- tokenLayerId,
1744
- namespace,
1745
- configKey,
1746
- configValue,
1747
- auditMessage,
1748
- ) =>
1896
+ setConfig: (tokenLayerId, namespace, configKey, configValue, auditMessage, options) =>
1749
1897
  this._request(
1750
1898
  "PUT",
1751
1899
  `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`,
1752
- { auditMessage, rawBody: configValue, skipResponseTransform: true },
1900
+ configRequest(auditMessage, configValue, options),
1753
1901
  ),
1754
1902
  /**
1755
1903
  * Remove a configuration value for a layer.
1756
1904
  * @param {string} tokenLayerId - The token layer ID
1757
1905
  * @param {string} namespace - The config namespace
1758
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.
1759
1909
  */
1760
- deleteConfig: (tokenLayerId, namespace, configKey, auditMessage) =>
1910
+ deleteConfig: (tokenLayerId, namespace, configKey, auditMessage, options) =>
1761
1911
  this._request(
1762
1912
  "DELETE",
1763
1913
  `/api/v1/token-layers/${tokenLayerId}/config/${namespace}/${configKey}`,
1764
- { auditMessage, skipResponseTransform: true },
1914
+ configRequest(auditMessage, undefined, options, true),
1765
1915
  ),
1916
+ ...constraintMethods(this, "token-layers"),
1766
1917
  /**
1767
1918
  * Get a token layer by ID.
1768
1919
  * @param {string} tokenLayerId - The token layer ID
@@ -1803,15 +1954,26 @@ class PlaidClient {
1803
1954
  * take it. While the lock is held, a second acquire is refused with
1804
1955
  * HTTP 423, whoever makes it, this user included.
1805
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
+ *
1806
1963
  * outOfBand: the lock is a signal, not project data (see the note at
1807
1964
  * the top of http.js). Queued on a batch it would be taken only at
1808
1965
  * submit, after every write it was meant to guard, and until then answer
1809
1966
  * success to a caller that does not hold it and cannot see the 423
1810
1967
  * saying somebody else does.
1811
1968
  * @param {string} documentId - The document ID
1969
+ * @param {string} [auditMessage]
1970
+ * @param {string} [newLockId] - A holder id this client minted
1812
1971
  */
1813
- acquireLock: (documentId, auditMessage) =>
1972
+ acquireLock: (documentId, auditMessage, newLockId) =>
1814
1973
  this._request("POST", `/api/v1/documents/${documentId}/lock`, {
1974
+ ...(newLockId !== undefined && newLockId !== null
1975
+ ? { queryParams: { "new-lock-id": newLockId } }
1976
+ : {}),
1815
1977
  auditMessage,
1816
1978
  outOfBand: true,
1817
1979
  }),
@@ -1872,7 +2034,10 @@ class PlaidClient {
1872
2034
  * The lock is per HOLDER: each block is its own, named by
1873
2035
  * `lock.lockId`, and only that id renews or releases it. NOT
1874
2036
  * re-entrant: a nested `locked()` block on one document is a second
1875
- * holder and gets the 423. Lock at exactly one level per call path. A
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
1876
2041
  * lost lock is recorded on the CLIENT, like strict mode, so it stops
1877
2042
  * every write the client makes (on any batch of it too) and not only the
1878
2043
  * ones this block makes.
@@ -1961,13 +2126,16 @@ class PlaidClient {
1961
2126
  * types, spelled as in an entry's `op/type` (e.g.
1962
2127
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
1963
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
1964
2131
  */
1965
- audit: (documentId, startTime, endTime, opTypes) =>
2132
+ audit: (documentId, startTime, endTime, opTypes, kinds) =>
1966
2133
  listAll(this, `/api/v1/documents/${documentId}/audit`, {
1967
2134
  query: {
1968
2135
  "start-time": startTime,
1969
2136
  "end-time": endTime,
1970
2137
  "op-types": opTypesParam(opTypes),
2138
+ kinds: kindsParam(kinds),
1971
2139
  },
1972
2140
  }),
1973
2141
  /**
@@ -1983,7 +2151,7 @@ class PlaidClient {
1983
2151
  */
1984
2152
  auditPage: (
1985
2153
  documentId,
1986
- { startTime, endTime, opTypes, order, limit, cursor } = {},
2154
+ { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
1987
2155
  ) =>
1988
2156
  listPage(this, `/api/v1/documents/${documentId}/audit`, {
1989
2157
  limit,
@@ -1992,6 +2160,7 @@ class PlaidClient {
1992
2160
  "start-time": startTime,
1993
2161
  "end-time": endTime,
1994
2162
  "op-types": opTypesParam(opTypes),
2163
+ kinds: kindsParam(kinds),
1995
2164
  order,
1996
2165
  },
1997
2166
  }),
@@ -2064,10 +2233,10 @@ class PlaidClient {
2064
2233
  * @param {string} name - The name
2065
2234
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
2066
2235
  */
2067
- create: (projectId, name, metadata, auditMessage) =>
2236
+ create: (projectId, name, metadata, auditMessage, { id } = {}) =>
2068
2237
  this._request("POST", "/api/v1/documents", {
2069
2238
  auditMessage,
2070
- body: bodyOf({ "project-id": projectId, name, metadata }),
2239
+ body: bodyOf({ id, "project-id": projectId, name, metadata }),
2071
2240
  }),
2072
2241
  /**
2073
2242
  * Copy a document and everything in it into a new document of the same
@@ -2083,10 +2252,10 @@ class PlaidClient {
2083
2252
  * file behind
2084
2253
  * @param {string} [auditMessage] - Custom audit message for this operation
2085
2254
  */
2086
- copy: (documentId, name, { includeMedia } = {}, auditMessage) =>
2255
+ copy: (documentId, name, { includeMedia, id } = {}, auditMessage) =>
2087
2256
  this._request("POST", `/api/v1/documents/${documentId}/copy`, {
2088
2257
  auditMessage,
2089
- body: bodyOf({ name, "include-media": includeMedia }),
2258
+ body: bodyOf({ id, name, "include-media": includeMedia }),
2090
2259
  }),
2091
2260
  };
2092
2261
 
@@ -2133,24 +2302,29 @@ class PlaidClient {
2133
2302
  * @param {string} namespace - The config namespace
2134
2303
  * @param {string} configKey - The config key
2135
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.
2136
2308
  */
2137
- setConfig: (id, namespace, configKey, configValue, auditMessage) =>
2309
+ setConfig: (id, namespace, configKey, configValue, auditMessage, options) =>
2138
2310
  this._request(
2139
2311
  "PUT",
2140
2312
  `/api/v1/projects/${id}/config/${namespace}/${configKey}`,
2141
- { auditMessage, rawBody: configValue, skipResponseTransform: true },
2313
+ configRequest(auditMessage, configValue, options),
2142
2314
  ),
2143
2315
  /**
2144
2316
  * Remove a configuration value for a project.
2145
2317
  * @param {string} id - The resource ID
2146
2318
  * @param {string} namespace - The config namespace
2147
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.
2148
2322
  */
2149
- deleteConfig: (id, namespace, configKey, auditMessage) =>
2323
+ deleteConfig: (id, namespace, configKey, auditMessage, options) =>
2150
2324
  this._request(
2151
2325
  "DELETE",
2152
2326
  `/api/v1/projects/${id}/config/${namespace}/${configKey}`,
2153
- { auditMessage, skipResponseTransform: true },
2327
+ configRequest(auditMessage, undefined, options, true),
2154
2328
  ),
2155
2329
  /**
2156
2330
  * Assign a user as a maintainer for this project.
@@ -2182,13 +2356,16 @@ class PlaidClient {
2182
2356
  * types, spelled as in an entry's `op/type` (e.g.
2183
2357
  * `['span-layer/create', 'span-layer/delete']`). An entry appears when
2184
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
2185
2361
  */
2186
- audit: (projectId, startTime, endTime, opTypes) =>
2362
+ audit: (projectId, startTime, endTime, opTypes, kinds) =>
2187
2363
  listAll(this, `/api/v1/projects/${projectId}/audit`, {
2188
2364
  query: {
2189
2365
  "start-time": startTime,
2190
2366
  "end-time": endTime,
2191
2367
  "op-types": opTypesParam(opTypes),
2368
+ kinds: kindsParam(kinds),
2192
2369
  },
2193
2370
  }),
2194
2371
  /**
@@ -2204,7 +2381,7 @@ class PlaidClient {
2204
2381
  */
2205
2382
  auditPage: (
2206
2383
  projectId,
2207
- { startTime, endTime, opTypes, order, limit, cursor } = {},
2384
+ { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2208
2385
  ) =>
2209
2386
  listPage(this, `/api/v1/projects/${projectId}/audit`, {
2210
2387
  limit,
@@ -2213,6 +2390,7 @@ class PlaidClient {
2213
2390
  "start-time": startTime,
2214
2391
  "end-time": endTime,
2215
2392
  "op-types": opTypesParam(opTypes),
2393
+ kinds: kindsParam(kinds),
2216
2394
  order,
2217
2395
  },
2218
2396
  }),
@@ -2345,10 +2523,10 @@ class PlaidClient {
2345
2523
  * Create a new project. Note: this also registers the user as a maintainer.
2346
2524
  * @param {string} name - The name
2347
2525
  */
2348
- create: (name, auditMessage) =>
2526
+ create: (name, auditMessage, { id } = {}) =>
2349
2527
  this._request("POST", "/api/v1/projects", {
2350
2528
  auditMessage,
2351
- body: bodyOf({ name }),
2529
+ body: bodyOf({ id, name }),
2352
2530
  }),
2353
2531
  };
2354
2532
 
@@ -2359,30 +2537,29 @@ class PlaidClient {
2359
2537
  * @param {string} namespace - The config namespace
2360
2538
  * @param {string} configKey - The config key
2361
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.
2362
2543
  */
2363
- setConfig: (
2364
- textLayerId,
2365
- namespace,
2366
- configKey,
2367
- configValue,
2368
- auditMessage,
2369
- ) =>
2544
+ setConfig: (textLayerId, namespace, configKey, configValue, auditMessage, options) =>
2370
2545
  this._request(
2371
2546
  "PUT",
2372
2547
  `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`,
2373
- { auditMessage, rawBody: configValue, skipResponseTransform: true },
2548
+ configRequest(auditMessage, configValue, options),
2374
2549
  ),
2375
2550
  /**
2376
2551
  * Remove a configuration value for a layer.
2377
2552
  * @param {string} textLayerId - The text layer ID
2378
2553
  * @param {string} namespace - The config namespace
2379
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.
2380
2557
  */
2381
- deleteConfig: (textLayerId, namespace, configKey, auditMessage) =>
2558
+ deleteConfig: (textLayerId, namespace, configKey, auditMessage, options) =>
2382
2559
  this._request(
2383
2560
  "DELETE",
2384
2561
  `/api/v1/text-layers/${textLayerId}/config/${namespace}/${configKey}`,
2385
- { auditMessage, skipResponseTransform: true },
2562
+ configRequest(auditMessage, undefined, options, true),
2386
2563
  ),
2387
2564
  /**
2388
2565
  * Get a text layer by ID.
@@ -2423,10 +2600,10 @@ class PlaidClient {
2423
2600
  * @param {string} projectId - The project ID
2424
2601
  * @param {string} name - The name
2425
2602
  */
2426
- create: (projectId, name, auditMessage) =>
2603
+ create: (projectId, name, auditMessage, { id } = {}) =>
2427
2604
  this._request("POST", "/api/v1/text-layers", {
2428
2605
  auditMessage,
2429
- body: bodyOf({ "project-id": projectId, name }),
2606
+ body: bodyOf({ id, "project-id": projectId, name }),
2430
2607
  }),
2431
2608
  };
2432
2609
 
@@ -2465,10 +2642,10 @@ class PlaidClient {
2465
2642
  * @param {string} form - The vocab item form
2466
2643
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
2467
2644
  */
2468
- create: (vocabLayerId, form, metadata, auditMessage) =>
2645
+ create: (vocabLayerId, form, metadata, auditMessage, { id } = {}) =>
2469
2646
  this._request("POST", "/api/v1/vocab-items", {
2470
2647
  auditMessage,
2471
- body: bodyOf({ "vocab-layer-id": vocabLayerId, form, metadata }),
2648
+ body: bodyOf({ id, "vocab-layer-id": vocabLayerId, form, metadata }),
2472
2649
  }),
2473
2650
  /**
2474
2651
  * Create multiple vocab items in a single operation. Entries may target
@@ -2515,9 +2692,35 @@ class PlaidClient {
2515
2692
  * of those links has its version bumped, and a strict-mode client picks
2516
2693
  * up their new versions from the response.
2517
2694
  * @param {string} id - The resource ID
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.
2518
2699
  */
2519
- delete: (id, auditMessage) =>
2520
- this._request("DELETE", `/api/v1/vocab-items/${id}`, { auditMessage }),
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
+ }),
2521
2724
  /**
2522
2725
  * Update a vocab item's form. A document read carries the entry's form on
2523
2726
  * every link to it, so a rename restates those documents: each has its
@@ -2550,10 +2753,10 @@ class PlaidClient {
2550
2753
  * @param {string} spanLayerId - The span layer ID
2551
2754
  * @param {string} name - The name
2552
2755
  */
2553
- create: (spanLayerId, name, auditMessage) =>
2756
+ create: (spanLayerId, name, auditMessage, { id } = {}) =>
2554
2757
  this._request("POST", "/api/v1/relation-layers", {
2555
2758
  auditMessage,
2556
- body: bodyOf({ "span-layer-id": spanLayerId, name }),
2759
+ body: bodyOf({ id, "span-layer-id": spanLayerId, name }),
2557
2760
  }),
2558
2761
  /**
2559
2762
  * Set a configuration value for a layer in an editor namespace.
@@ -2561,31 +2764,31 @@ class PlaidClient {
2561
2764
  * @param {string} namespace - The config namespace
2562
2765
  * @param {string} configKey - The config key
2563
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.
2564
2770
  */
2565
- setConfig: (
2566
- relationLayerId,
2567
- namespace,
2568
- configKey,
2569
- configValue,
2570
- auditMessage,
2571
- ) =>
2771
+ setConfig: (relationLayerId, namespace, configKey, configValue, auditMessage, options) =>
2572
2772
  this._request(
2573
2773
  "PUT",
2574
2774
  `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`,
2575
- { auditMessage, rawBody: configValue, skipResponseTransform: true },
2775
+ configRequest(auditMessage, configValue, options),
2576
2776
  ),
2577
2777
  /**
2578
2778
  * Remove a configuration value for a layer.
2579
2779
  * @param {string} relationLayerId - The relation layer ID
2580
2780
  * @param {string} namespace - The config namespace
2581
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.
2582
2784
  */
2583
- deleteConfig: (relationLayerId, namespace, configKey, auditMessage) =>
2785
+ deleteConfig: (relationLayerId, namespace, configKey, auditMessage, options) =>
2584
2786
  this._request(
2585
2787
  "DELETE",
2586
2788
  `/api/v1/relation-layers/${relationLayerId}/config/${namespace}/${configKey}`,
2587
- { auditMessage, skipResponseTransform: true },
2789
+ configRequest(auditMessage, undefined, options, true),
2588
2790
  ),
2791
+ ...constraintMethods(this, "relation-layers"),
2589
2792
  /**
2590
2793
  * Get a relation layer by ID.
2591
2794
  * @param {string} relationLayerId - The relation layer ID
@@ -2639,10 +2842,12 @@ class PlaidClient {
2639
2842
  precedence,
2640
2843
  metadata,
2641
2844
  auditMessage,
2845
+ { id } = {},
2642
2846
  ) =>
2643
2847
  this._request("POST", "/api/v1/tokens", {
2644
2848
  auditMessage,
2645
2849
  body: bodyOf({
2850
+ id,
2646
2851
  "token-layer-id": tokenLayerId,
2647
2852
  text,
2648
2853
  begin,
@@ -2707,11 +2912,21 @@ class PlaidClient {
2707
2912
  * left half (keeps its ID, spans, vocab-links); the new right token's ID is returned.
2708
2913
  * @param {string} tokenId - The token ID
2709
2914
  * @param {number} position - Code-point offset to split at (strictly between begin and end)
2710
- */
2711
- split: (tokenId, position, auditMessage) =>
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
+ ) =>
2712
2927
  this._request("POST", `/api/v1/tokens/${tokenId}/split`, {
2713
2928
  auditMessage,
2714
- body: bodyOf({ position }),
2929
+ body: bodyOf({ id, position }),
2715
2930
  }),
2716
2931
  /**
2717
2932
  * Merge two tokens. The left token (smaller begin) survives with the combined
@@ -3000,9 +3215,13 @@ class PlaidClient {
3000
3215
  * @param {object} [opts]
3001
3216
  * @param {string} [opts.anchorLabel] - What the comment is about, in words (at most 200 characters)
3002
3217
  */
3003
- create: (entityType, entityId, body, { anchorLabel } = {}) =>
3218
+ create: (entityType, entityId, body, { anchorLabel, id } = {}) =>
3004
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,
3005
3223
  body: bodyOf({
3224
+ id,
3006
3225
  "entity-type": entityType,
3007
3226
  "entity-id": entityId,
3008
3227
  body,
@@ -3021,13 +3240,17 @@ class PlaidClient {
3021
3240
  * @param {string} body - The replacement text
3022
3241
  */
3023
3242
  update: (id, body) =>
3024
- this._request("PATCH", `/api/v1/comments/${id}`, { body: { body } }),
3243
+ this._request("PATCH", `/api/v1/comments/${id}`, {
3244
+ body: { body },
3245
+ noOperation: true,
3246
+ }),
3025
3247
  /**
3026
3248
  * Delete a comment. The author may delete their own; a project
3027
3249
  * maintainer (or admin) may delete any.
3028
3250
  * @param {string} id - The comment id
3029
3251
  */
3030
- delete: (id) => this._request("DELETE", `/api/v1/comments/${id}`),
3252
+ delete: (id) =>
3253
+ this._request("DELETE", `/api/v1/comments/${id}`, { noOperation: true }),
3031
3254
  /**
3032
3255
  * List comments in a project, oldest first. Transparently follows
3033
3256
  * pagination cursors and returns the full flat array.
@@ -3279,10 +3502,10 @@ class PlaidClient {
3279
3502
  * @param {string} [opts.body] - The Markdown text (up to 20000 characters; may be empty)
3280
3503
  * @param {boolean} [opts.pinned] - Send this one to the assistant in full on every turn
3281
3504
  */
3282
- create: (projectId, title, { body, pinned } = {}, auditMessage) =>
3505
+ create: (projectId, title, { body, pinned, id } = {}, auditMessage) =>
3283
3506
  this._request("POST", `/api/v1/projects/${projectId}/guidelines`, {
3284
3507
  auditMessage,
3285
- body: bodyOf({ title, body, pinned }),
3508
+ body: bodyOf({ id, title, body, pinned }),
3286
3509
  }),
3287
3510
  /**
3288
3511
  * Read one guideline, Markdown body included.
@@ -3414,20 +3637,47 @@ class PlaidClient {
3414
3637
  *
3415
3638
  * `kind` says what kind of operation this is, for a program reading the
3416
3639
  * log: one of `assistant-plan`, `service-run`, `import`, `bulk-edit`,
3417
- * `guess-adoption` or `repair` (the server refuses any other). `ref` is a
3418
- * short string naming what the operation came from, in the shape its kind
3419
- * documents (the core manual, "Kinds of operation"). Both are recorded from
3420
- * the first write like the label, and a nested operation keeps the outer
3421
- * one's.
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.
3422
3654
  *
3423
3655
  * @param {string} message - Human label for the operation.
3424
- * @param {object} [opts] - Optional `{ id, kind, ref }`. `id` adopts an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically). `kind` and `ref` are described above.
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.
3425
3657
  * @returns {string} The operation's group id.
3426
3658
  */
3427
- beginOperation(message, { id, kind, ref } = {}) {
3428
- if (this.operationGroup) {
3429
- this.operationGroup.depth += 1;
3430
- 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;
3431
3681
  }
3432
3682
  this.operationGroup = {
3433
3683
  id: id || crypto.randomUUID(),
@@ -3437,10 +3687,22 @@ class PlaidClient {
3437
3687
  depth: 1,
3438
3688
  written: false,
3439
3689
  refined: undefined,
3690
+ frames: [frame],
3440
3691
  };
3692
+ this._openedFrame = frame;
3441
3693
  return this.operationGroup.id;
3442
3694
  }
3443
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
+
3444
3706
  /**
3445
3707
  * End the current logical operation. With no argument this is purely local
3446
3708
  * (no request). Pass a refined `message` to relabel the group now that the
@@ -3454,6 +3716,12 @@ class PlaidClient {
3454
3716
  const group = this.operationGroup;
3455
3717
  if (!group) return;
3456
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
+ }
3457
3725
  group.depth -= 1;
3458
3726
  return;
3459
3727
  }
@@ -3486,22 +3754,40 @@ class PlaidClient {
3486
3754
  *
3487
3755
  * @param {string} message - Human label for the operation.
3488
3756
  * @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
3489
- * @param {object} [opts] - Optional `{ kind, ref }`, as for beginOperation.
3757
+ * @param {object} [opts] - Optional `{ kind, ref, id, keys }`, as for beginOperation.
3490
3758
  * @returns {Promise<any>} Whatever `fn` resolves to.
3491
3759
  */
3492
- async withOperation(message, fn, { kind, ref } = {}) {
3493
- this.beginOperation(message, { kind, ref });
3760
+ async withOperation(message, fn, { kind, ref, id, keys, minted } = {}) {
3761
+ this.beginOperation(message, { kind, ref, id, keys, minted });
3494
3762
  const group = this.operationGroup;
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;
3495
3767
  const setMessage = (msg) => {
3496
3768
  if (group.depth === 1) group.refined = msg;
3497
3769
  };
3770
+ let result;
3498
3771
  try {
3499
- return await fn(setMessage);
3500
- } finally {
3501
- 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;
3502
3784
  }
3785
+ dropFrame(group, frame);
3786
+ await this.endOperation();
3787
+ return result;
3503
3788
  }
3504
3789
 
3790
+
3505
3791
  /**
3506
3792
  * Open a batch: a view of this client with the same bundles, on which every
3507
3793
  * write of project data queues instead of going out. `submit()` sends the
@@ -3533,14 +3819,17 @@ class PlaidClient {
3533
3819
  /**
3534
3820
  * POST one batch request (at most MAX_BATCH_OPS operations). Resolves to
3535
3821
  * one `{ status, headers, body }` per operation, with only the body
3536
- * recased: the headers keep the server's spelling.
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.
3537
3825
  */
3538
- async _postBatch(url, body) {
3826
+ async _postBatch(url, body, { key = uuidv7() } = {}) {
3539
3827
  const fetchOptions = {
3540
3828
  method: "POST",
3541
3829
  headers: {
3542
3830
  Authorization: `Bearer ${this.token}`,
3543
3831
  "Content-Type": "application/json",
3832
+ [IDEMPOTENCY_HEADER]: key,
3544
3833
  },
3545
3834
  body: JSON.stringify(body),
3546
3835
  };
@@ -3549,17 +3838,26 @@ class PlaidClient {
3549
3838
  // Retry a 503: the batch is atomic, so a refused one wrote nothing and
3550
3839
  // repeating it is safe. A fresh timeout signal per attempt — an
3551
3840
  // AbortSignal.timeout stays aborted once it fires.
3552
- const response = await retryWhileBusy(async () => {
3841
+ const attempt = async () => {
3553
3842
  const signal = timeoutSignal(this.batchTimeout);
3554
- const res = await fetch(
3555
- url,
3556
- signal ? { ...fetchOptions, signal } : fetchOptions,
3557
- );
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
+ }
3558
3852
  if (!res.ok) {
3559
3853
  throw makeHttpError(res, await parseErrorBody(res), url, "POST");
3560
3854
  }
3561
3855
  return res;
3856
+ };
3857
+ const response = await retryUnknown(() => retryWhileBusy(attempt), {
3858
+ delaysMs: this.retryDelaysMs,
3562
3859
  }).catch((error) => {
3860
+ error.idempotencyKey = key;
3563
3861
  // 401 means the token is missing, expired or invalid: fire the app's
3564
3862
  // auth-error handler once, exactly as makeRequest does.
3565
3863
  if (
@@ -3578,6 +3876,8 @@ class PlaidClient {
3578
3876
  });
3579
3877
 
3580
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";
3581
3881
 
3582
3882
  // Extract document versions from each batch response
3583
3883
  for (const result of results) {
@@ -3588,10 +3888,11 @@ class PlaidClient {
3588
3888
  );
3589
3889
  if (typeof versionsMap === "object" && versionsMap !== null) {
3590
3890
  // Clone once per response, then merge — not once per entry.
3591
- this.documentVersions = {
3592
- ...this.documentVersions,
3593
- ...versionsMap,
3594
- };
3891
+ this.documentVersions = mergeVersions(
3892
+ this.documentVersions,
3893
+ versionsMap,
3894
+ replayed,
3895
+ );
3595
3896
  }
3596
3897
  } catch (e) {
3597
3898
  console.warn(
@@ -3602,13 +3903,16 @@ class PlaidClient {
3602
3903
  }
3603
3904
  }
3604
3905
 
3605
- return results.map((result) => ({
3906
+ const out = results.map((result) => ({
3606
3907
  ...result,
3607
3908
  body: transformResponse(result.body),
3608
3909
  }));
3910
+ return replayed ? markReplayed(out) : out;
3609
3911
  } catch (error) {
3610
- if (error.status) throw error;
3611
- throw makeNetworkError(error, url, "POST");
3912
+ if (error.status !== undefined) throw error;
3913
+ throw Object.assign(makeNetworkError(error, url, "POST"), {
3914
+ idempotencyKey: key,
3915
+ });
3612
3916
  }
3613
3917
  }
3614
3918
 
@@ -3777,8 +4081,102 @@ class PlaidClient {
3777
4081
  }
3778
4082
  }
3779
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
+
3780
4177
  export default PlaidClient;
3781
4178
  export { PlaidClient };
4179
+ export { CONSTRAINT_TYPES, valueSetAllows, violationsOf } from "./constraints.js";
3782
4180
 
3783
4181
  // Unicode code-point helpers for text offsets (token begin/end are code-point
3784
4182
  // indices). See ./codepoint.js.
@@ -3790,10 +4188,13 @@ export {
3790
4188
  cpToUtf16,
3791
4189
  cpIndexOf,
3792
4190
  } from "./codepoint.js";
4191
+ export { composeTextEdits, gapsToOps, applyTextOps } from "./textEdits.js";
3793
4192
  export {
3794
4193
  PLAID_NAMESPACE,
3795
4194
  ROLE_KEY,
3796
4195
  PRESERVE_ON_SPLIT_KEY,
4196
+ PLAIN_EDITS_KEY,
4197
+ SPLIT_ON_SPACE_KEY,
3797
4198
  ROLES,
3798
4199
  readRole,
3799
4200
  findByRole,
@@ -3866,4 +4267,8 @@ export {
3866
4267
  // The ids a create or bulk create answered with, off the call or its batch
3867
4268
  // result. See ./created.js.
3868
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";
3869
4274
  export { MAX_BATCH_OPS };