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

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/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @larc-iu/plaid-client
2
+
3
+ The JavaScript client for the [Plaid](https://larc-iu.github.io/plaid/) annotation API.
4
+ It has no dependencies and runs in browsers and in Node.
5
+
6
+ ## From npm
7
+
8
+ ```sh
9
+ npm install @larc-iu/plaid-client@alpha
10
+ ```
11
+
12
+ ```js
13
+ import { PlaidClient } from "@larc-iu/plaid-client";
14
+
15
+ const client = await PlaidClient.login("http://localhost:8080", "user@example.com", "password");
16
+ console.log(await client.projects.list());
17
+ ```
18
+
19
+ ## From a Plaid server
20
+
21
+ Every Plaid server serves the client that matches its own version at `/client/plaid-client.js`, with its type declarations at `/client/plaid-client.d.ts`.
22
+ A page served by Plaid, for example from the folder set as `static_resources_path` in its `config.toml`, can import it without bundling a copy:
23
+
24
+ ```html
25
+ <script type="module">
26
+ import { PlaidClient } from "/client/plaid-client.js";
27
+ const client = await PlaidClient.login(location.origin, "user@example.com", "password");
28
+ console.log(await client.projects.list());
29
+ </script>
30
+ ```
31
+
32
+ ## Documentation
33
+
34
+ - [Manual](https://larc-iu.github.io/plaid/manual.html), which covers the API and its concepts.
35
+ - [API reference](https://larc-iu.github.io/plaid/api-js.html) for this client.
package/index.d.ts CHANGED
@@ -422,6 +422,8 @@ interface UsersBundle {
422
422
  interface UserDataEntry {
423
423
  key: string;
424
424
  updatedAt: string;
425
+ /** Raised by one on every write of the entry. A new entry is 1. */
426
+ version: number;
425
427
  value?: any;
426
428
  }
427
429
 
@@ -446,11 +448,18 @@ interface UserDataBundle {
446
448
  opts?: UserDataOptions & { pageSize?: number },
447
449
  ): AsyncGenerator<UserDataEntry[]>;
448
450
  get(userId: string, key: string): Promise<UserDataEntry & { value: any }>;
451
+ /**
452
+ * `version`: write only when the entry is still at this version (0: only
453
+ * when there is none). Refused with 409, `error: "version-mismatch"` and the
454
+ * stored `version` and `updatedAt` on the error's `responseData`, when
455
+ * another write landed since.
456
+ */
449
457
  put(
450
458
  userId: string,
451
459
  key: string,
452
460
  value: any,
453
- ): Promise<{ key: string; updatedAt: string }>;
461
+ opts?: { version?: number },
462
+ ): Promise<{ key: string; updatedAt: string; version: number }>;
454
463
  delete(userId: string, key: string): Promise<any>;
455
464
  }
456
465
 
@@ -923,6 +932,10 @@ interface AuditPageOptions {
923
932
  order?: "asc" | "desc";
924
933
  limit?: number;
925
934
  cursor?: string;
935
+ /** Keep only each entry's oldest N operations in `ops` (1..1000). Every entry has `opCount`. */
936
+ opsLimit?: number;
937
+ /** Read only this entry rather than a page. */
938
+ entryId?: string;
926
939
  }
927
940
 
928
941
  /** One user's activity over a scope and window. */
@@ -1115,7 +1128,14 @@ interface DocumentsBundle {
1115
1128
  onProgress?: (progress: { loaded: number; total: number | null }) => void;
1116
1129
  },
1117
1130
  ): Promise<any>;
1118
- deleteMedia(documentId: string, auditMessage?: string): Promise<any>;
1131
+ deleteMedia(
1132
+ documentId: string,
1133
+ auditMessage?: string,
1134
+ options?: {
1135
+ /** The recording meant, the `?v=` of the document's `mediaUrl`. A 409 when the stored one is another. */
1136
+ mediaVersion?: string;
1137
+ },
1138
+ ): Promise<any>;
1119
1139
  setMetadata(
1120
1140
  documentId: string,
1121
1141
  body: any,
@@ -1458,7 +1478,7 @@ interface TokensBundle {
1458
1478
  tokenId: string,
1459
1479
  position: number,
1460
1480
  auditMessage?: string,
1461
- options?: { id?: string },
1481
+ options?: { id?: string; keep?: "left" | "right" },
1462
1482
  ): Promise<any>;
1463
1483
  merge(
1464
1484
  tokenId: string,
@@ -1652,7 +1672,7 @@ export declare class PlaidClient {
1652
1672
  } | null;
1653
1673
  beginOperation(
1654
1674
  message: string,
1655
- opts?: { id?: string; kind?: OperationKind; ref?: string; keys?: KeySeed; minted?: Set<string> },
1675
+ opts?: { groupId?: string; kind?: OperationKind; ref?: string; keys?: KeySeed; minted?: Set<string> },
1656
1676
  ): string;
1657
1677
  /** A seed for the Idempotency-Keys of an operation that may be run again (see beginOperation's `keys`). */
1658
1678
  keySeed(): KeySeed;
@@ -1660,7 +1680,7 @@ export declare class PlaidClient {
1660
1680
  withOperation<T>(
1661
1681
  message: string,
1662
1682
  fn: (setMessage: (msg: string) => void) => Promise<T> | T,
1663
- opts?: { kind?: OperationKind; ref?: string; id?: string; keys?: KeySeed; minted?: Set<string> },
1683
+ opts?: { kind?: OperationKind; ref?: string; groupId?: string; keys?: KeySeed; minted?: Set<string> },
1664
1684
  ): Promise<T>;
1665
1685
  operationGroups: OperationGroupsBundle;
1666
1686
 
@@ -1893,6 +1913,8 @@ export interface KeySeed {
1893
1913
  }
1894
1914
  /** A fresh UUIDv7, for the id of something a client creates. */
1895
1915
  export function uuidv7(): string;
1916
+ /** A fresh random UUID (version 4), from crypto.getRandomValues, so it works on any page. */
1917
+ export function uuidv4(): string;
1896
1918
  /** Whether a write's answer was replayed from its Idempotency-Key's first send (`Idempotent-Replayed: true`), so it wrote nothing new. Marked on an object or list answer as a non-enumerable `replayed: true`. */
1897
1919
  export function wasReplayed(answer: unknown): boolean;
1898
1920
  export function createdId(result: any): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.21",
3
+ "version": "0.2.0-alpha.23",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  import { isUnknownOutcome, retryUnknown } from "./http.js";
2
+ import { uuidv4 } from "./ids.js";
2
3
 
3
4
  /**
4
5
  * Keeping a document lock alive for as long as a `locked()` block runs.
@@ -216,7 +217,7 @@ export class DocumentLock {
216
217
  export const LOCK_ACQUIRE_RETRY_MS = 500;
217
218
 
218
219
  function mintLockId() {
219
- return globalThis.crypto.randomUUID();
220
+ return uuidv4();
220
221
  }
221
222
 
222
223
  /**
package/src/http.js CHANGED
@@ -69,6 +69,23 @@ export const DEFAULT_TIMEOUT_MS = 30000;
69
69
  // nothing and costs a retry stacked on a write that is still running.
70
70
  export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
71
71
 
72
+ // A query may run for core's own query limit (30 s, `*query-timeout-ms*` in
73
+ // plaid.sql.query.exec), and core then answers 408. The client waits a little
74
+ // longer than that, so the caller gets core's 408 for a query too broad and
75
+ // not a timeout of its own that reads like a lost connection.
76
+ export const DEFAULT_QUERY_TIMEOUT_MS = 35000;
77
+
78
+ /**
79
+ * The timeout for a query: the client's own, raised to
80
+ * DEFAULT_QUERY_TIMEOUT_MS when it is shorter. A disabled timeout (0, null)
81
+ * stays disabled.
82
+ */
83
+ export function queryTimeout(client) {
84
+ const t = client.timeout;
85
+ if (!t || t <= 0) return t;
86
+ return Math.max(t, DEFAULT_QUERY_TIMEOUT_MS);
87
+ }
88
+
72
89
  /**
73
90
  * Note how far the server's clock is from this machine's, from a response's
74
91
  * Date header, for `client.serverNow()`. A time the server stamped (an audit
@@ -738,6 +755,10 @@ export async function queueRequest(batch, method, path, options = {}) {
738
755
  * skipResponseTransform - Return raw parsed JSON (no transformResponse)
739
756
  * noAuth - Skip Authorization header
740
757
  * binaryResponse - Return arrayBuffer instead of JSON/text
758
+ * noBusyRetry - If true, a 503 is not retried. For a query: core
759
+ * answers 503 when its queue of large queries stays full
760
+ * until the query's deadline, and asking again at once
761
+ * only joins the same queue.
741
762
  * timeout - Per-request timeout in ms overriding client.timeout
742
763
  * for this call (0/null disables). Used for known-long
743
764
  * ops like project delete.
@@ -756,6 +777,7 @@ export async function makeRequest(client, method, path, options = {}) {
756
777
  timeout,
757
778
  onUploadProgress,
758
779
  learningOmittedVersion,
780
+ noBusyRetry,
759
781
  } = options;
760
782
  if (method !== "GET" && omittedStrictDocument(client)) {
761
783
  await learnOmittedVersion(client);
@@ -825,12 +847,13 @@ export async function makeRequest(client, method, path, options = {}) {
825
847
 
826
848
  try {
827
849
  const onRetry = (info) => client._noteRetry?.(info);
850
+ const busy = noBusyRetry ? { onRetry, retries: 0 } : { onRetry };
828
851
  const response = keyed
829
- ? await retryUnknown(() => retryWhileBusy(attempt, { onRetry }), {
852
+ ? await retryUnknown(() => retryWhileBusy(attempt, busy), {
830
853
  delaysMs: client.retryDelaysMs,
831
854
  onRetry,
832
855
  })
833
- : await retryWhileBusy(attempt, { onRetry });
856
+ : await retryWhileBusy(attempt, busy);
834
857
  noteServerClock(client, response.headers);
835
858
 
836
859
  if (!response.ok) {
package/src/ids.js CHANGED
@@ -52,3 +52,23 @@ export function uuidv7() {
52
52
  }
53
53
  return s;
54
54
  }
55
+
56
+ /**
57
+ * A random UUIDv4, for ids that need no order (an operation group, a lock
58
+ * holder). Built on `crypto.getRandomValues`, since `crypto.randomUUID`
59
+ * exists only in a secure context and a page served over plain HTTP from
60
+ * another host than localhost is not one.
61
+ * @returns {string}
62
+ */
63
+ export function uuidv4() {
64
+ const b = new Uint8Array(16);
65
+ crypto.getRandomValues(b);
66
+ b[6] = (b[6] & 0x0f) | 0x40;
67
+ b[8] = (b[8] & 0x3f) | 0x80;
68
+ let s = "";
69
+ for (let i = 0; i < 16; i++) {
70
+ s += hex[b[i]];
71
+ if (i === 3 || i === 5 || i === 7 || i === 9) s += "-";
72
+ }
73
+ return s;
74
+ }
package/src/index.js CHANGED
@@ -20,8 +20,9 @@ import {
20
20
  REPLAYED_HEADER,
21
21
  DEFAULT_TIMEOUT_MS,
22
22
  DEFAULT_BATCH_TIMEOUT_MS,
23
+ queryTimeout,
23
24
  } from "./http.js";
24
- import { uuidv7 } from "./ids.js";
25
+ import { uuidv4, uuidv7 } from "./ids.js";
25
26
  import { constraintsBody } from "./constraints.js";
26
27
  import { listAll, listPage, iterPages } from "./pagination.js";
27
28
  import { recorderFor } from "./events.js";
@@ -133,6 +134,21 @@ async function anonymousGet(baseUrl, path, options = {}) {
133
134
  */
134
135
  const MAX_BATCH_OPS = 1000;
135
136
 
137
+ /**
138
+ * The kinds an operation may say it is (`?group-kind=`), the server's closed
139
+ * list (plaid.sql.operation-group/kinds). An operation of any other kind is
140
+ * refused when it begins, before it writes anything.
141
+ */
142
+ const OPERATION_KINDS = [
143
+ "assistant-plan",
144
+ "service-run",
145
+ "import",
146
+ "bulk-edit",
147
+ "guess-adoption",
148
+ "repair",
149
+ "review",
150
+ ];
151
+
136
152
  /**
137
153
  * How long `admin.backup()` waits for the server to finish writing a backup:
138
154
  * minutes on a large database, past the usual per-request timeout.
@@ -528,13 +544,17 @@ class PlaidClient {
528
544
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
529
545
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
530
546
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
547
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
548
+ * operations in `ops` (1..1000). `opCount` on every entry says how
549
+ * many it has
550
+ * @param {string} [opts.entryId] - Read only this entry, not a page
531
551
  * @param {string} [opts.itemId] - Only the changes that wrote this one
532
552
  * entry, each with only its operations that did
533
553
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
534
554
  */
535
555
  auditPage: (
536
556
  id,
537
- { startTime, endTime, opTypes, kinds, order, limit, cursor, itemId } = {},
557
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId, itemId } = {},
538
558
  ) =>
539
559
  listPage(this, `/api/v1/vocab-layers/${id}/audit`, {
540
560
  limit,
@@ -545,6 +565,8 @@ class PlaidClient {
545
565
  "op-types": opTypesParam(opTypes),
546
566
  kinds: kindsParam(kinds),
547
567
  order,
568
+ "ops-limit": opsLimit,
569
+ "entry-id": entryId,
548
570
  "item-id": itemId,
549
571
  },
550
572
  }),
@@ -1173,14 +1195,14 @@ class PlaidClient {
1173
1195
  * List (or search) users. Transparently follows pagination cursors and
1174
1196
  * returns the full flat array. Admin-or-maintainer only.
1175
1197
  * @param {object} [opts]
1176
- * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
1198
+ * @param {string} [opts.q] - Filter to users whose display name contains this text, or whose email starts with it (contains it, when the text has an @), case-insensitive
1177
1199
  */
1178
1200
  list: ({ q } = {}) =>
1179
1201
  listAll(this, "/api/v1/users", { query: { q } }),
1180
1202
  /**
1181
1203
  * Fetch a single page of users (optionally filtered by `q`).
1182
1204
  * @param {object} [opts]
1183
- * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
1205
+ * @param {string} [opts.q] - Filter to users whose display name contains this text, or whose email starts with it (contains it, when the text has an @), case-insensitive
1184
1206
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1185
1207
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1186
1208
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
@@ -1194,7 +1216,7 @@ class PlaidClient {
1194
1216
  /**
1195
1217
  * Async-iterate users page by page; yields each page's entries array.
1196
1218
  * @param {object} [opts]
1197
- * @param {string} [opts.q] - Filter to users whose display name or email contains this text (case-insensitive)
1219
+ * @param {string} [opts.q] - Filter to users whose display name contains this text, or whose email starts with it (contains it, when the text has an @), case-insensitive
1198
1220
  * @param {number} [opts.pageSize] - Per-request page size
1199
1221
  * @returns {AsyncGenerator<Array>}
1200
1222
  */
@@ -1256,11 +1278,15 @@ class PlaidClient {
1256
1278
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
1257
1279
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1258
1280
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1281
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
1282
+ * operations in `ops` (1..1000). `opCount` on every entry says how
1283
+ * many it has
1284
+ * @param {string} [opts.entryId] - Read only this entry, not a page
1259
1285
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1260
1286
  */
1261
1287
  auditPage: (
1262
1288
  userId,
1263
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
1289
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
1264
1290
  ) =>
1265
1291
  listPage(this, `/api/v1/users/${userId}/audit`, {
1266
1292
  limit,
@@ -1271,6 +1297,8 @@ class PlaidClient {
1271
1297
  "op-types": opTypesParam(opTypes),
1272
1298
  kinds: kindsParam(kinds),
1273
1299
  order,
1300
+ "ops-limit": opsLimit,
1301
+ "entry-id": entryId,
1274
1302
  },
1275
1303
  }),
1276
1304
  /**
@@ -1446,7 +1474,7 @@ class PlaidClient {
1446
1474
  query: { prefix, pattern, "include-values": includeValues },
1447
1475
  }),
1448
1476
  /**
1449
- * Read one private data entry ({key, updatedAt, value}); 404 if absent.
1477
+ * Read one private data entry ({key, updatedAt, version, value}); 404 if absent.
1450
1478
  * @param {string} userId
1451
1479
  * @param {string} key
1452
1480
  */
@@ -1464,17 +1492,28 @@ class PlaidClient {
1464
1492
  * value whose keys are camelCase round-trips unchanged while one keyed
1465
1493
  * by arbitrary strings does not. Put such a map under a `metadata` key,
1466
1494
  * which both clients pass through untouched.
1495
+ *
1496
+ * Every write raises the entry's `version` by one, and reads answer it.
1497
+ * With `version`, the write lands only when the entry is still at that
1498
+ * version (0: only when there is no entry), and is otherwise refused with
1499
+ * 409, `error: "version-mismatch"`, and the stored `version` and
1500
+ * `updatedAt` on the error's `responseData`: read it again and make the
1501
+ * change on what is there.
1467
1502
  * @param {string} userId
1468
1503
  * @param {string} key
1469
1504
  * @param {*} value
1505
+ * @param {object} [opts]
1506
+ * @param {number} [opts.version] - The version this write was made from
1507
+ * @returns {Promise<{key: string, updatedAt: string, version: number}>}
1470
1508
  */
1471
- put: (userId, key, value) =>
1509
+ put: (userId, key, value, { version } = {}) =>
1472
1510
  this._request(
1473
1511
  "PUT",
1474
1512
  `/api/v1/users/${userId}/data/${encodeURIComponent(key)}`,
1475
1513
  {
1476
1514
  body: value,
1477
1515
  noBatch: true,
1516
+ queryParams: { version },
1478
1517
  },
1479
1518
  ),
1480
1519
  /**
@@ -1839,9 +1878,13 @@ class PlaidClient {
1839
1878
  * is what a feed wants. A cursor belongs to the direction that made it.
1840
1879
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1841
1880
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1881
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
1882
+ * operations in `ops` (1..1000). `opCount` on every entry says how
1883
+ * many it has
1884
+ * @param {string} [opts.entryId] - Read only this entry, not a page
1842
1885
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1843
1886
  */
1844
- listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor } = {}) =>
1887
+ listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {}) =>
1845
1888
  listPage(this, "/api/v1/audit", {
1846
1889
  limit,
1847
1890
  cursor,
@@ -1851,6 +1894,8 @@ class PlaidClient {
1851
1894
  "op-types": opTypesParam(opTypes),
1852
1895
  kinds: kindsParam(kinds),
1853
1896
  order,
1897
+ "ops-limit": opsLimit,
1898
+ "entry-id": entryId,
1854
1899
  },
1855
1900
  }),
1856
1901
  /**
@@ -2112,6 +2157,11 @@ class PlaidClient {
2112
2157
  }),
2113
2158
  /**
2114
2159
  * Upload a media file for a document. Uses Apache Tika for content validation.
2160
+ * A document that already has a recording refuses it with a 409
2161
+ * (`media-exists`, and the current `media-url`): delete that one first.
2162
+ * A WAV whose samples are not PCM, 32-bit float, A-law or mu-law (IMA or
2163
+ * MS ADPCM, GSM, 64-bit float and the rest), which browsers cannot play,
2164
+ * is refused with a 415 whose `error` names its coding.
2115
2165
  * @param {string} documentId - The document ID
2116
2166
  * @param {File} file - The file to upload
2117
2167
  * @param {string} [auditMessage] - Custom audit-log message for this write
@@ -2131,15 +2181,21 @@ class PlaidClient {
2131
2181
  /**
2132
2182
  * Delete media file for a document
2133
2183
  * @param {string} documentId - The document ID
2184
+ * @param {string} [auditMessage] - Audit message for this write
2185
+ * @param {{mediaVersion?: string}} [options] - `mediaVersion`: the
2186
+ * recording meant, the `?v=` of the document's `mediaUrl`. The delete
2187
+ * is refused with a 409 (`media-changed`, and the current `media-url`)
2188
+ * when the stored recording is another one.
2134
2189
  */
2135
2190
  // The upload above is multipart and cannot be batched, but a DELETE
2136
2191
  // carries no blob, so the batch transport takes it. It is a write
2137
2192
  // of the document's own data and queues like any other. Note that the
2138
2193
  // file removal happens outside the server's transaction, so a batch that
2139
2194
  // aborts after this op does not bring the file back.
2140
- deleteMedia: (documentId, auditMessage) =>
2195
+ deleteMedia: (documentId, auditMessage, { mediaVersion } = {}) =>
2141
2196
  this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
2142
2197
  auditMessage,
2198
+ queryParams: { "media-version": mediaVersion },
2143
2199
  }),
2144
2200
  /**
2145
2201
  * Replace all metadata for a document.
@@ -2171,7 +2227,10 @@ class PlaidClient {
2171
2227
  }),
2172
2228
  /**
2173
2229
  * Get audit log for a document. Transparently follows pagination cursors
2174
- * and returns the full flat array.
2230
+ * and returns the full flat array. Each entry, and each of its `ops`,
2231
+ * says what kind of credential made it as `credential`: `login`,
2232
+ * `named-token`, `service` or `delegated` (absent on older operations),
2233
+ * beside `apiToken` (the named token's id and name) when there was one.
2175
2234
  * @param {string} documentId - The document ID
2176
2235
  * @param {string} [startTime] - Start of time range
2177
2236
  * @param {string} [endTime] - End of time range
@@ -2200,11 +2259,15 @@ class PlaidClient {
2200
2259
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2201
2260
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2202
2261
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2262
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
2263
+ * operations in `ops` (1..1000). `opCount` on every entry says how
2264
+ * many it has
2265
+ * @param {string} [opts.entryId] - Read only this entry, not a page
2203
2266
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2204
2267
  */
2205
2268
  auditPage: (
2206
2269
  documentId,
2207
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2270
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
2208
2271
  ) =>
2209
2272
  listPage(this, `/api/v1/documents/${documentId}/audit`, {
2210
2273
  limit,
@@ -2215,6 +2278,8 @@ class PlaidClient {
2215
2278
  "op-types": opTypesParam(opTypes),
2216
2279
  kinds: kindsParam(kinds),
2217
2280
  order,
2281
+ "ops-limit": opsLimit,
2282
+ "entry-id": entryId,
2218
2283
  },
2219
2284
  }),
2220
2285
  /**
@@ -2401,7 +2466,10 @@ class PlaidClient {
2401
2466
  ),
2402
2467
  /**
2403
2468
  * Get audit log for a project. Transparently follows pagination cursors
2404
- * and returns the full flat array.
2469
+ * and returns the full flat array. Each entry, and each of its `ops`,
2470
+ * says what kind of credential made it as `credential`: `login`,
2471
+ * `named-token`, `service` or `delegated` (absent on older operations),
2472
+ * beside `apiToken` (the named token's id and name) when there was one.
2405
2473
  * @param {string} projectId - The project ID
2406
2474
  * @param {string} [startTime] - Start of time range
2407
2475
  * @param {string} [endTime] - End of time range
@@ -2430,11 +2498,15 @@ class PlaidClient {
2430
2498
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2431
2499
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2432
2500
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2501
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
2502
+ * operations in `ops` (1..1000). `opCount` on every entry says how
2503
+ * many it has
2504
+ * @param {string} [opts.entryId] - Read only this entry, not a page
2433
2505
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2434
2506
  */
2435
2507
  auditPage: (
2436
2508
  projectId,
2437
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2509
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
2438
2510
  ) =>
2439
2511
  listPage(this, `/api/v1/projects/${projectId}/audit`, {
2440
2512
  limit,
@@ -2445,6 +2517,8 @@ class PlaidClient {
2445
2517
  "op-types": opTypesParam(opTypes),
2446
2518
  kinds: kindsParam(kinds),
2447
2519
  order,
2520
+ "ops-limit": opsLimit,
2521
+ "entry-id": entryId,
2448
2522
  },
2449
2523
  }),
2450
2524
  /**
@@ -2762,8 +2836,11 @@ class PlaidClient {
2762
2836
  * losers are deleted. Links are read when the merge runs, so one made
2763
2837
  * after the caller looked moves too. Every loser must be in the
2764
2838
  * survivor's vocabulary, and a loser that is already gone is skipped, so
2765
- * a repeated merge changes nothing. References to a loser in other
2766
- * entries' metadata are the caller's to rewrite, in the same batch.
2839
+ * a repeated merge changes nothing. Metadata naming a loser by its id,
2840
+ * in the vocabulary's other entries and in the documents of every
2841
+ * project it is linked to, is rewritten to name the survivor. A
2842
+ * reference to a loser in the survivor's own metadata is the caller's to
2843
+ * rewrite, in the same batch.
2767
2844
  * Needs maintainer rights on the vocabulary.
2768
2845
  * @param {string} survivorId - The entry that stays
2769
2846
  * @param {string[]} loserIds - The entries merged into it
@@ -2966,8 +3043,11 @@ class PlaidClient {
2966
3043
  * @param {string} tokenId - The token ID
2967
3044
  * @param {number} position - Code-point offset to split at (strictly between begin and end)
2968
3045
  * @param {string} [auditMessage] - Audit message for this write
2969
- * @param {{id?: string}} [options] - `id`: the right half's id, a UUIDv7 this
2970
- * client minted. A relation layer whose relations must stay inside one token
3046
+ * @param {{id?: string, keep?: "left"|"right"}} [options] - `id`: the new
3047
+ * token's id, a UUIDv7 this client minted. `keep`: the half the original token
3048
+ * (its id, spans, vocab-links, comments and metadata) stays on, `"left"` by
3049
+ * default. With `"right"` the new token, whose id is answered, is the left
3050
+ * half. A relation layer whose relations must stay inside one token
2971
3051
  * of this layer declares a same-ancestor constraint, and the server deletes
2972
3052
  * the relations the split leaves crossing in the same transaction.
2973
3053
  */
@@ -2975,11 +3055,11 @@ class PlaidClient {
2975
3055
  tokenId,
2976
3056
  position,
2977
3057
  auditMessage,
2978
- { id } = {},
3058
+ { id, keep } = {},
2979
3059
  ) =>
2980
3060
  this._request("POST", `/api/v1/tokens/${tokenId}/split`, {
2981
3061
  auditMessage,
2982
- body: bodyOf({ id, position }),
3062
+ body: bodyOf({ id, keep, position }),
2983
3063
  }),
2984
3064
  /**
2985
3065
  * Merge two tokens. The left token (smaller begin) survives with the combined
@@ -3228,9 +3308,16 @@ class PlaidClient {
3228
3308
  */
3229
3309
  // outOfBand: a query is a read that travels as a POST (see the note at
3230
3310
  // the top of http.js). Made on a batch it goes over the wire like any
3231
- // read, and it never joins a logical operation.
3311
+ // read, and it never joins a logical operation. It waits past core's own
3312
+ // query limit, so a query too broad answers with core's 408, and a 503
3313
+ // from core's full queue of large queries is not retried.
3232
3314
  this.query = (body) =>
3233
- this._request("POST", "/api/v1/query", { body, outOfBand: true });
3315
+ this._request("POST", "/api/v1/query", {
3316
+ body,
3317
+ outOfBand: true,
3318
+ timeout: queryTimeout(this),
3319
+ noBusyRetry: true,
3320
+ });
3234
3321
 
3235
3322
  // Logical-operation groups (audit-log grouping). There is no create: a
3236
3323
  // group row is made lazily by the first write carrying `?group-id=`
@@ -3691,6 +3778,7 @@ class PlaidClient {
3691
3778
  * `kind` says what kind of operation this is, for a program reading the
3692
3779
  * log: one of `assistant-plan`, `service-run`, `import`, `bulk-edit`,
3693
3780
  * `guess-adoption`, `repair` or `review` (the server refuses any other).
3781
+ * Any other kind throws here, before anything is written.
3694
3782
  * `ref` is a short string naming what the operation came from, in the
3695
3783
  * shape its kind documents (the core manual, "Kinds of operation"). Both
3696
3784
  * are recorded from the first write like the label, and a nested operation
@@ -3706,10 +3794,15 @@ class PlaidClient {
3706
3794
  * matching end, and the outer numbering resumes after it.
3707
3795
  *
3708
3796
  * @param {string} message - Human label for the operation.
3709
- * @param {object} [opts] - Optional `{ id, kind, ref, keys }`. `id` adopts an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically). `kind`, `ref` and `keys` are described above.
3797
+ * @param {object} [opts] - Optional `{ groupId, kind, ref, keys, minted }`. `groupId` 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). Python's is `group_id`. `kind`, `ref` and `keys` are described above. `minted` is the ids the operation mints for what it creates: a create refused 409 id-taken for one of them was made by an earlier send of the operation, and answers as made.
3710
3798
  * @returns {string} The operation's group id.
3711
3799
  */
3712
- beginOperation(message, { id, kind, ref, keys, minted } = {}) {
3800
+ beginOperation(message, { groupId, kind, ref, keys, minted } = {}) {
3801
+ if (kind != null && !OPERATION_KINDS.includes(String(kind))) {
3802
+ throw new Error(
3803
+ `Unknown operation kind "${kind}". It is one of: ${OPERATION_KINDS.join(", ")}.`,
3804
+ );
3805
+ }
3713
3806
  const frame = {
3714
3807
  keys: keys || null,
3715
3808
  count: 0,
@@ -3733,7 +3826,7 @@ class PlaidClient {
3733
3826
  return open.id;
3734
3827
  }
3735
3828
  this.operationGroup = {
3736
- id: id || crypto.randomUUID(),
3829
+ id: groupId ? String(groupId) : uuidv4(),
3737
3830
  message: message == null ? null : String(message),
3738
3831
  kind: kind == null ? null : String(kind),
3739
3832
  ref: ref == null ? null : String(ref),
@@ -3807,11 +3900,11 @@ class PlaidClient {
3807
3900
  *
3808
3901
  * @param {string} message - Human label for the operation.
3809
3902
  * @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
3810
- * @param {object} [opts] - Optional `{ kind, ref, id, keys }`, as for beginOperation.
3903
+ * @param {object} [opts] - Optional `{ kind, ref, groupId, keys, minted }`, as for beginOperation. `kind` is one of `assistant-plan`, `service-run`, `import`, `bulk-edit`, `guess-adoption`, `repair` or `review`, and any other throws before `fn` runs.
3811
3904
  * @returns {Promise<any>} Whatever `fn` resolves to.
3812
3905
  */
3813
- async withOperation(message, fn, { kind, ref, id, keys, minted } = {}) {
3814
- this.beginOperation(message, { kind, ref, id, keys, minted });
3906
+ async withOperation(message, fn, { kind, ref, groupId, keys, minted } = {}) {
3907
+ this.beginOperation(message, { kind, ref, groupId, keys, minted });
3815
3908
  const group = this.operationGroup;
3816
3909
  // The key frame this call opened is ended by this call, whatever else
3817
3910
  // began or ended meanwhile, not by whichever end comes at its depth.
@@ -3957,6 +4050,28 @@ class PlaidClient {
3957
4050
  }
3958
4051
  }
3959
4052
  }
4053
+ // The batch's own header holds each document's version after the whole
4054
+ // batch, the layer rules' remedies at its end included, which no single
4055
+ // operation's answer does: a split that deletes a relation crossing
4056
+ // the new boundary moves the version once more.
4057
+ const outerVersions = response.headers?.get?.("X-Document-Versions");
4058
+ if (outerVersions) {
4059
+ try {
4060
+ const versionsMap = JSON.parse(outerVersions);
4061
+ if (typeof versionsMap === "object" && versionsMap !== null) {
4062
+ this.documentVersions = mergeVersions(
4063
+ this.documentVersions,
4064
+ versionsMap,
4065
+ replayed,
4066
+ );
4067
+ }
4068
+ } catch (e) {
4069
+ console.warn(
4070
+ "Failed to parse document versions header from batch response:",
4071
+ e,
4072
+ );
4073
+ }
4074
+ }
3960
4075
 
3961
4076
  const out = results.map((result) => ({
3962
4077
  ...result,
@@ -4314,7 +4429,8 @@ export {
4314
4429
  // result. See ./created.js.
4315
4430
  export { createdId, createdIds } from "./created.js";
4316
4431
  // UUIDv7 ids for what a client creates. See ./ids.js and the manual,
4317
- // "Retrying a write".
4318
- export { uuidv7 } from "./ids.js";
4432
+ // "Retrying a write". uuidv4 works where crypto.randomUUID does not (a page
4433
+ // served over plain HTTP from any host but localhost).
4434
+ export { uuidv4, uuidv7 } from "./ids.js";
4319
4435
  export { wasReplayed } from "./replayed.js";
4320
4436
  export { MAX_BATCH_OPS };
package/src/services.js CHANGED
@@ -444,7 +444,8 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
444
444
  * @param {string} serviceId - Service ID to request
445
445
  * @param {any} data - Request payload
446
446
  * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms.
447
- * Every event it sends starts the clock again, so this does not cap a long run
447
+ * Every event it sends starts the clock again, so this does not cap a long run.
448
+ * 0, a negative number or null: no limit (wait until `signal` stops it)
448
449
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
449
450
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
450
451
  * @param {Object} [opts]
@@ -494,8 +495,15 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
494
495
  url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
495
496
  method: 'POST',
496
497
  body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
498
+ // `notLive` (with `serviceId`) lets an app say it in the service's own
499
+ // name rather than its id.
497
500
  onStatus: (response) =>
498
- response.status === 503 ? new Error(`No live service '${serviceId}' on this project`) : null,
501
+ response.status === 503
502
+ ? Object.assign(new Error(`No live service '${serviceId}' on this project`), {
503
+ notLive: true,
504
+ serviceId,
505
+ })
506
+ : null,
499
507
  what: 'Service request',
500
508
  },
501
509
  timeout,
@@ -526,6 +534,7 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
526
534
  * @param {string} projectId - Project UUID
527
535
  * @param {string} requestId - The request id (from `onAccepted` or your own)
528
536
  * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms
537
+ * (0, a negative number or null: no limit)
529
538
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
530
539
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
531
540
  * @returns {Promise<any>} The service's result
@@ -702,9 +711,11 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
702
711
  // deadline on the whole run it killed working transcriptions at the
703
712
  // five-minute default and left their writes to land on a document the page
704
713
  // had already handed back to the user as editable.
714
+ // 0, a negative number or null is no limit, as for every other timeout.
705
715
  let timer;
706
716
  const waitAgain = () => {
707
717
  clearTimeout(timer);
718
+ if (!(timeout > 0)) return;
708
719
  timer = setTimeout(
709
720
  () =>
710
721
  finish(reject, stillRunning(new Error(`${what} timed out after ${timeout}ms of silence`))),
package/src/textEdits.js CHANGED
@@ -20,6 +20,22 @@
20
20
 
21
21
  const codePoints = (s) => [...s];
22
22
 
23
+ // The last body split into code points, kept: an edit log composes the same
24
+ // long body once for each of its kept states, and splitting it each time made
25
+ // a save on a long text block the page (H31-TEXT-1). Frozen, as every caller
26
+ // shares it.
27
+ let lastBody = null;
28
+ let lastPoints = Object.freeze([]);
29
+
30
+ /** The code points of `body`, as a frozen array shared with other callers. */
31
+ export function bodyCodePoints(body) {
32
+ if (body !== lastBody) {
33
+ lastPoints = Object.freeze(codePoints(body));
34
+ lastBody = body;
35
+ }
36
+ return lastPoints;
37
+ }
38
+
23
39
  function opName(op) {
24
40
  return JSON.stringify(op);
25
41
  }
@@ -98,7 +114,7 @@ function cutAt(segs, at) {
98
114
  * malformed or out-of-bounds op.
99
115
  */
100
116
  export function composeTextEdits(body, ops) {
101
- const old = codePoints(body);
117
+ const old = bodyCodePoints(body);
102
118
  let segs = old.length ? [{ old: true, start: 0, end: old.length }] : [];
103
119
  let len = old.length;
104
120
  for (const op of ops) {
@@ -128,7 +144,7 @@ export function composeTextEdits(body, ops) {
128
144
  close(seg.start);
129
145
  kept = seg.end;
130
146
  } else {
131
- typed.push(...seg.chars);
147
+ for (const c of seg.chars) typed.push(c);
132
148
  }
133
149
  }
134
150
  close(old.length);