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

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/index.d.ts CHANGED
@@ -923,6 +923,10 @@ interface AuditPageOptions {
923
923
  order?: "asc" | "desc";
924
924
  limit?: number;
925
925
  cursor?: string;
926
+ /** Keep only each entry's oldest N operations in `ops` (1..1000). Every entry has `opCount`. */
927
+ opsLimit?: number;
928
+ /** Read only this entry rather than a page. */
929
+ entryId?: string;
926
930
  }
927
931
 
928
932
  /** One user's activity over a scope and window. */
@@ -1115,7 +1119,14 @@ interface DocumentsBundle {
1115
1119
  onProgress?: (progress: { loaded: number; total: number | null }) => void;
1116
1120
  },
1117
1121
  ): Promise<any>;
1118
- deleteMedia(documentId: string, auditMessage?: string): Promise<any>;
1122
+ deleteMedia(
1123
+ documentId: string,
1124
+ auditMessage?: string,
1125
+ options?: {
1126
+ /** The recording meant, the `?v=` of the document's `mediaUrl`. A 409 when the stored one is another. */
1127
+ mediaVersion?: string;
1128
+ },
1129
+ ): Promise<any>;
1119
1130
  setMetadata(
1120
1131
  documentId: string,
1121
1132
  body: any,
@@ -1458,7 +1469,7 @@ interface TokensBundle {
1458
1469
  tokenId: string,
1459
1470
  position: number,
1460
1471
  auditMessage?: string,
1461
- options?: { id?: string },
1472
+ options?: { id?: string; keep?: "left" | "right" },
1462
1473
  ): Promise<any>;
1463
1474
  merge(
1464
1475
  tokenId: string,
@@ -1652,7 +1663,7 @@ export declare class PlaidClient {
1652
1663
  } | null;
1653
1664
  beginOperation(
1654
1665
  message: string,
1655
- opts?: { id?: string; kind?: OperationKind; ref?: string; keys?: KeySeed; minted?: Set<string> },
1666
+ opts?: { groupId?: string; kind?: OperationKind; ref?: string; keys?: KeySeed; minted?: Set<string> },
1656
1667
  ): string;
1657
1668
  /** A seed for the Idempotency-Keys of an operation that may be run again (see beginOperation's `keys`). */
1658
1669
  keySeed(): KeySeed;
@@ -1660,7 +1671,7 @@ export declare class PlaidClient {
1660
1671
  withOperation<T>(
1661
1672
  message: string,
1662
1673
  fn: (setMessage: (msg: string) => void) => Promise<T> | T,
1663
- opts?: { kind?: OperationKind; ref?: string; id?: string; keys?: KeySeed; minted?: Set<string> },
1674
+ opts?: { kind?: OperationKind; ref?: string; groupId?: string; keys?: KeySeed; minted?: Set<string> },
1664
1675
  ): Promise<T>;
1665
1676
  operationGroups: OperationGroupsBundle;
1666
1677
 
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.22",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
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/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  REPLAYED_HEADER,
21
21
  DEFAULT_TIMEOUT_MS,
22
22
  DEFAULT_BATCH_TIMEOUT_MS,
23
+ queryTimeout,
23
24
  } from "./http.js";
24
25
  import { uuidv7 } from "./ids.js";
25
26
  import { constraintsBody } from "./constraints.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
  /**
@@ -1839,9 +1867,13 @@ class PlaidClient {
1839
1867
  * is what a feed wants. A cursor belongs to the direction that made it.
1840
1868
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1841
1869
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1870
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
1871
+ * operations in `ops` (1..1000). `opCount` on every entry says how
1872
+ * many it has
1873
+ * @param {string} [opts.entryId] - Read only this entry, not a page
1842
1874
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1843
1875
  */
1844
- listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor } = {}) =>
1876
+ listPage: ({ startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {}) =>
1845
1877
  listPage(this, "/api/v1/audit", {
1846
1878
  limit,
1847
1879
  cursor,
@@ -1851,6 +1883,8 @@ class PlaidClient {
1851
1883
  "op-types": opTypesParam(opTypes),
1852
1884
  kinds: kindsParam(kinds),
1853
1885
  order,
1886
+ "ops-limit": opsLimit,
1887
+ "entry-id": entryId,
1854
1888
  },
1855
1889
  }),
1856
1890
  /**
@@ -2112,6 +2146,11 @@ class PlaidClient {
2112
2146
  }),
2113
2147
  /**
2114
2148
  * Upload a media file for a document. Uses Apache Tika for content validation.
2149
+ * A document that already has a recording refuses it with a 409
2150
+ * (`media-exists`, and the current `media-url`): delete that one first.
2151
+ * A WAV whose samples are not PCM or float (IMA or MS ADPCM, GSM, A-law
2152
+ * and the rest), which browsers cannot play, is refused with a 415
2153
+ * whose `error` names its coding.
2115
2154
  * @param {string} documentId - The document ID
2116
2155
  * @param {File} file - The file to upload
2117
2156
  * @param {string} [auditMessage] - Custom audit-log message for this write
@@ -2131,15 +2170,21 @@ class PlaidClient {
2131
2170
  /**
2132
2171
  * Delete media file for a document
2133
2172
  * @param {string} documentId - The document ID
2173
+ * @param {string} [auditMessage] - Audit message for this write
2174
+ * @param {{mediaVersion?: string}} [options] - `mediaVersion`: the
2175
+ * recording meant, the `?v=` of the document's `mediaUrl`. The delete
2176
+ * is refused with a 409 (`media-changed`, and the current `media-url`)
2177
+ * when the stored recording is another one.
2134
2178
  */
2135
2179
  // The upload above is multipart and cannot be batched, but a DELETE
2136
2180
  // carries no blob, so the batch transport takes it. It is a write
2137
2181
  // of the document's own data and queues like any other. Note that the
2138
2182
  // file removal happens outside the server's transaction, so a batch that
2139
2183
  // aborts after this op does not bring the file back.
2140
- deleteMedia: (documentId, auditMessage) =>
2184
+ deleteMedia: (documentId, auditMessage, { mediaVersion } = {}) =>
2141
2185
  this._request("DELETE", `/api/v1/documents/${documentId}/media`, {
2142
2186
  auditMessage,
2187
+ queryParams: { "media-version": mediaVersion },
2143
2188
  }),
2144
2189
  /**
2145
2190
  * Replace all metadata for a document.
@@ -2200,11 +2245,15 @@ class PlaidClient {
2200
2245
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2201
2246
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2202
2247
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2248
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
2249
+ * operations in `ops` (1..1000). `opCount` on every entry says how
2250
+ * many it has
2251
+ * @param {string} [opts.entryId] - Read only this entry, not a page
2203
2252
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2204
2253
  */
2205
2254
  auditPage: (
2206
2255
  documentId,
2207
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2256
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
2208
2257
  ) =>
2209
2258
  listPage(this, `/api/v1/documents/${documentId}/audit`, {
2210
2259
  limit,
@@ -2215,6 +2264,8 @@ class PlaidClient {
2215
2264
  "op-types": opTypesParam(opTypes),
2216
2265
  kinds: kindsParam(kinds),
2217
2266
  order,
2267
+ "ops-limit": opsLimit,
2268
+ "entry-id": entryId,
2218
2269
  },
2219
2270
  }),
2220
2271
  /**
@@ -2430,11 +2481,15 @@ class PlaidClient {
2430
2481
  * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
2431
2482
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
2432
2483
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
2484
+ * @param {number} [opts.opsLimit] - Keep only each entry's oldest N
2485
+ * operations in `ops` (1..1000). `opCount` on every entry says how
2486
+ * many it has
2487
+ * @param {string} [opts.entryId] - Read only this entry, not a page
2433
2488
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2434
2489
  */
2435
2490
  auditPage: (
2436
2491
  projectId,
2437
- { startTime, endTime, opTypes, kinds, order, limit, cursor } = {},
2492
+ { startTime, endTime, opTypes, kinds, order, limit, cursor, opsLimit, entryId } = {},
2438
2493
  ) =>
2439
2494
  listPage(this, `/api/v1/projects/${projectId}/audit`, {
2440
2495
  limit,
@@ -2445,6 +2500,8 @@ class PlaidClient {
2445
2500
  "op-types": opTypesParam(opTypes),
2446
2501
  kinds: kindsParam(kinds),
2447
2502
  order,
2503
+ "ops-limit": opsLimit,
2504
+ "entry-id": entryId,
2448
2505
  },
2449
2506
  }),
2450
2507
  /**
@@ -2762,8 +2819,11 @@ class PlaidClient {
2762
2819
  * losers are deleted. Links are read when the merge runs, so one made
2763
2820
  * after the caller looked moves too. Every loser must be in the
2764
2821
  * 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.
2822
+ * a repeated merge changes nothing. Metadata naming a loser by its id,
2823
+ * in the vocabulary's other entries and in the documents of every
2824
+ * project it is linked to, is rewritten to name the survivor. A
2825
+ * reference to a loser in the survivor's own metadata is the caller's to
2826
+ * rewrite, in the same batch.
2767
2827
  * Needs maintainer rights on the vocabulary.
2768
2828
  * @param {string} survivorId - The entry that stays
2769
2829
  * @param {string[]} loserIds - The entries merged into it
@@ -2966,8 +3026,11 @@ class PlaidClient {
2966
3026
  * @param {string} tokenId - The token ID
2967
3027
  * @param {number} position - Code-point offset to split at (strictly between begin and end)
2968
3028
  * @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
3029
+ * @param {{id?: string, keep?: "left"|"right"}} [options] - `id`: the new
3030
+ * token's id, a UUIDv7 this client minted. `keep`: the half the original token
3031
+ * (its id, spans, vocab-links, comments and metadata) stays on, `"left"` by
3032
+ * default. With `"right"` the new token, whose id is answered, is the left
3033
+ * half. A relation layer whose relations must stay inside one token
2971
3034
  * of this layer declares a same-ancestor constraint, and the server deletes
2972
3035
  * the relations the split leaves crossing in the same transaction.
2973
3036
  */
@@ -2975,11 +3038,11 @@ class PlaidClient {
2975
3038
  tokenId,
2976
3039
  position,
2977
3040
  auditMessage,
2978
- { id } = {},
3041
+ { id, keep } = {},
2979
3042
  ) =>
2980
3043
  this._request("POST", `/api/v1/tokens/${tokenId}/split`, {
2981
3044
  auditMessage,
2982
- body: bodyOf({ id, position }),
3045
+ body: bodyOf({ id, keep, position }),
2983
3046
  }),
2984
3047
  /**
2985
3048
  * Merge two tokens. The left token (smaller begin) survives with the combined
@@ -3228,9 +3291,16 @@ class PlaidClient {
3228
3291
  */
3229
3292
  // outOfBand: a query is a read that travels as a POST (see the note at
3230
3293
  // 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.
3294
+ // read, and it never joins a logical operation. It waits past core's own
3295
+ // query limit, so a query too broad answers with core's 408, and a 503
3296
+ // from core's full queue of large queries is not retried.
3232
3297
  this.query = (body) =>
3233
- this._request("POST", "/api/v1/query", { body, outOfBand: true });
3298
+ this._request("POST", "/api/v1/query", {
3299
+ body,
3300
+ outOfBand: true,
3301
+ timeout: queryTimeout(this),
3302
+ noBusyRetry: true,
3303
+ });
3234
3304
 
3235
3305
  // Logical-operation groups (audit-log grouping). There is no create: a
3236
3306
  // group row is made lazily by the first write carrying `?group-id=`
@@ -3691,6 +3761,7 @@ class PlaidClient {
3691
3761
  * `kind` says what kind of operation this is, for a program reading the
3692
3762
  * log: one of `assistant-plan`, `service-run`, `import`, `bulk-edit`,
3693
3763
  * `guess-adoption`, `repair` or `review` (the server refuses any other).
3764
+ * Any other kind throws here, before anything is written.
3694
3765
  * `ref` is a short string naming what the operation came from, in the
3695
3766
  * shape its kind documents (the core manual, "Kinds of operation"). Both
3696
3767
  * are recorded from the first write like the label, and a nested operation
@@ -3706,10 +3777,15 @@ class PlaidClient {
3706
3777
  * matching end, and the outer numbering resumes after it.
3707
3778
  *
3708
3779
  * @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.
3780
+ * @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
3781
  * @returns {string} The operation's group id.
3711
3782
  */
3712
- beginOperation(message, { id, kind, ref, keys, minted } = {}) {
3783
+ beginOperation(message, { groupId, kind, ref, keys, minted } = {}) {
3784
+ if (kind != null && !OPERATION_KINDS.includes(String(kind))) {
3785
+ throw new Error(
3786
+ `Unknown operation kind "${kind}". It is one of: ${OPERATION_KINDS.join(", ")}.`,
3787
+ );
3788
+ }
3713
3789
  const frame = {
3714
3790
  keys: keys || null,
3715
3791
  count: 0,
@@ -3733,7 +3809,7 @@ class PlaidClient {
3733
3809
  return open.id;
3734
3810
  }
3735
3811
  this.operationGroup = {
3736
- id: id || crypto.randomUUID(),
3812
+ id: groupId ? String(groupId) : crypto.randomUUID(),
3737
3813
  message: message == null ? null : String(message),
3738
3814
  kind: kind == null ? null : String(kind),
3739
3815
  ref: ref == null ? null : String(ref),
@@ -3807,11 +3883,11 @@ class PlaidClient {
3807
3883
  *
3808
3884
  * @param {string} message - Human label for the operation.
3809
3885
  * @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.
3886
+ * @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
3887
  * @returns {Promise<any>} Whatever `fn` resolves to.
3812
3888
  */
3813
- async withOperation(message, fn, { kind, ref, id, keys, minted } = {}) {
3814
- this.beginOperation(message, { kind, ref, id, keys, minted });
3889
+ async withOperation(message, fn, { kind, ref, groupId, keys, minted } = {}) {
3890
+ this.beginOperation(message, { kind, ref, groupId, keys, minted });
3815
3891
  const group = this.operationGroup;
3816
3892
  // The key frame this call opened is ended by this call, whatever else
3817
3893
  // began or ended meanwhile, not by whichever end comes at its depth.
@@ -3957,6 +4033,28 @@ class PlaidClient {
3957
4033
  }
3958
4034
  }
3959
4035
  }
4036
+ // The batch's own header holds each document's version after the whole
4037
+ // batch, the layer rules' remedies at its end included, which no single
4038
+ // operation's answer does: a split that deletes a relation crossing
4039
+ // the new boundary moves the version once more.
4040
+ const outerVersions = response.headers?.get?.("X-Document-Versions");
4041
+ if (outerVersions) {
4042
+ try {
4043
+ const versionsMap = JSON.parse(outerVersions);
4044
+ if (typeof versionsMap === "object" && versionsMap !== null) {
4045
+ this.documentVersions = mergeVersions(
4046
+ this.documentVersions,
4047
+ versionsMap,
4048
+ replayed,
4049
+ );
4050
+ }
4051
+ } catch (e) {
4052
+ console.warn(
4053
+ "Failed to parse document versions header from batch response:",
4054
+ e,
4055
+ );
4056
+ }
4057
+ }
3960
4058
 
3961
4059
  const out = results.map((result) => ({
3962
4060
  ...result,
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);