@larc-iu/plaid-client 0.2.0-alpha.7 → 0.2.0-alpha.9

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
@@ -312,14 +312,30 @@ interface CommentFilters {
312
312
  }
313
313
 
314
314
  /** Entities that can carry a comment. */
315
- type CommentableType = "document" | "text" | "token" | "span" | "relation";
315
+ type CommentableType =
316
+ | "document"
317
+ | "text"
318
+ | "token"
319
+ | "span"
320
+ | "relation"
321
+ | "vocab-item";
316
322
 
317
323
  interface Comment {
318
324
  id: string;
319
- projectId: string;
320
- documentId: string;
325
+ /** The owning project, or null for a comment on a vocabulary entry. */
326
+ projectId: string | null;
327
+ /** The owning document, or null for a comment on a vocabulary entry. */
328
+ documentId: string | null;
329
+ /** The owning vocab layer, set only for a comment on a vocabulary entry. */
330
+ vocabLayerId: string | null;
321
331
  entityType: CommentableType;
322
332
  entityId: string;
333
+ /**
334
+ * What the comment is about, in words, captured when it was posted. A
335
+ * comment outlives its anchor, and this is what is left to show once the
336
+ * anchor has been deleted.
337
+ */
338
+ anchorLabel: string | null;
323
339
  authorId: string;
324
340
  body: string;
325
341
  createdAt: string;
@@ -333,6 +349,7 @@ interface CommentsBundle {
333
349
  entityType: CommentableType,
334
350
  entityId: string,
335
351
  body: string,
352
+ opts?: { anchorLabel?: string },
336
353
  ): Promise<Comment>;
337
354
  get(id: string): Promise<Comment>;
338
355
  update(id: string, body: string): Promise<Comment>;
@@ -351,6 +368,20 @@ interface CommentsBundle {
351
368
  projectId: string,
352
369
  filters?: CommentFilters,
353
370
  ): Promise<Record<string, number>>;
371
+ /** Comments on a vocabulary's entries, oldest first. */
372
+ listInVocab(
373
+ vocabId: string,
374
+ filters?: { entityId?: string },
375
+ ): Promise<Comment[]>;
376
+ listInVocabPage(
377
+ vocabId: string,
378
+ opts?: { entityId?: string; limit?: number; cursor?: string },
379
+ ): Promise<Page<Comment>>;
380
+ /** `{entryId: count}` over a vocabulary; keys are raw entry ids. */
381
+ countsInVocab(
382
+ vocabId: string,
383
+ filters?: { entityId?: string },
384
+ ): Promise<Record<string, number>>;
354
385
  }
355
386
 
356
387
  interface ApiTokensBundle {
@@ -473,11 +504,15 @@ interface DocumentsBundle {
473
504
  checkLock(documentId: string, asOf?: string): Promise<any>;
474
505
  acquireLock(documentId: string, auditMessage?: string): Promise<any>;
475
506
  releaseLock(documentId: string, auditMessage?: string): Promise<any>;
476
- getMedia(documentId: string, asOf?: string): Promise<ArrayBuffer>;
507
+ getMedia(documentId: string): Promise<ArrayBuffer>;
477
508
  uploadMedia(
478
509
  documentId: string,
479
510
  file: File,
480
511
  auditMessage?: string,
512
+ options?: {
513
+ /** Called with the bytes sent so far as the file goes up (`total` is null when unknown). */
514
+ onProgress?: (progress: { loaded: number; total: number | null }) => void;
515
+ },
481
516
  ): Promise<any>;
482
517
  deleteMedia(documentId: string, auditMessage?: string): Promise<any>;
483
518
  setMetadata(
@@ -852,6 +887,9 @@ export function cpIndexOf(s: string, sub: string, fromCp?: number): number;
852
887
  export const PLAID_NAMESPACE: "plaid";
853
888
  /** The config key, under `plaid`, holding a layer's role. */
854
889
  export const ROLE_KEY: "role";
890
+
891
+ /** The server's cap on operations per batch request; a larger batch goes as consecutive requests. */
892
+ export const MAX_BATCH_OPS: 1000;
855
893
  /** The fixed role inventory; only these values are interoperable across apps. */
856
894
  export const ROLES: {
857
895
  readonly BASELINE: "baseline";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.7",
3
+ "version": "0.2.0-alpha.9",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
package/src/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { transformRequest, transformResponse } from './transforms.js';
1
+ import { transformRequest, transformResponse } from "./transforms.js";
2
2
 
3
3
  // Default per-request timeout (ms). Applied to every request unless the client
4
4
  // is constructed with a different `timeout` (0 / null disables it). Note: this
@@ -13,25 +13,40 @@ export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
13
13
 
14
14
  /**
15
15
  * Extract and update document versions from response headers and body.
16
+ *
17
+ * `historical` marks a read made with `as-of`: its body carries the version
18
+ * the document HAD then, which is not what a strict-mode write must claim
19
+ * next, so the body is ignored and only the header (always the live
20
+ * version) is learned from. Without this, viewing an old state and then
21
+ * writing 409s on a version that stopped being current long ago.
16
22
  */
17
- export function extractDocumentVersions(client, responseHeaders, responseBody = null) {
18
- const docVersionsHeader = responseHeaders.get('X-Document-Versions');
23
+ export function extractDocumentVersions(
24
+ client,
25
+ responseHeaders,
26
+ responseBody = null,
27
+ { historical = false } = {},
28
+ ) {
29
+ const docVersionsHeader = responseHeaders.get("X-Document-Versions");
19
30
  if (docVersionsHeader) {
20
31
  try {
21
32
  const versionsMap = JSON.parse(docVersionsHeader);
22
- if (typeof versionsMap === 'object' && versionsMap !== null) {
33
+ if (typeof versionsMap === "object" && versionsMap !== null) {
23
34
  // Clone once, then assign — cloning inside the loop is O(n²) and pointless.
24
- client.documentVersions = { ...client.documentVersions, ...versionsMap };
35
+ client.documentVersions = {
36
+ ...client.documentVersions,
37
+ ...versionsMap,
38
+ };
25
39
  }
26
40
  } catch (e) {
27
- console.warn('Failed to parse document versions header:', e);
41
+ console.warn("Failed to parse document versions header:", e);
28
42
  }
29
43
  }
30
44
 
31
- if (responseBody && typeof responseBody === 'object') {
32
- if (responseBody['document/id'] && responseBody['document/version']) {
45
+ if (!historical && responseBody && typeof responseBody === "object") {
46
+ if (responseBody["document/id"] && responseBody["document/version"]) {
33
47
  client.documentVersions = { ...client.documentVersions };
34
- client.documentVersions[responseBody['document/id']] = responseBody['document/version'];
48
+ client.documentVersions[responseBody["document/id"]] =
49
+ responseBody["document/version"];
35
50
  }
36
51
  }
37
52
  }
@@ -43,7 +58,11 @@ export async function parseErrorBody(response) {
43
58
  try {
44
59
  return await response.json();
45
60
  } catch (_) {
46
- return { message: await response.text().catch(() => 'Unable to read error response') };
61
+ return {
62
+ message: await response
63
+ .text()
64
+ .catch(() => "Unable to read error response"),
65
+ };
47
66
  }
48
67
  }
49
68
 
@@ -51,7 +70,11 @@ export async function parseErrorBody(response) {
51
70
  * Create an enriched error from a failed HTTP response.
52
71
  */
53
72
  export function makeHttpError(response, errorData, url, method) {
54
- const serverMessage = errorData?.error || errorData?.message || response.statusText || 'Unknown error';
73
+ const serverMessage =
74
+ errorData?.error ||
75
+ errorData?.message ||
76
+ response.statusText ||
77
+ "Unknown error";
55
78
  const error = new Error(`HTTP ${response.status} ${serverMessage} at ${url}`);
56
79
  error.status = response.status;
57
80
  error.statusText = response.statusText;
@@ -65,7 +88,9 @@ export function makeHttpError(response, errorData, url, method) {
65
88
  * Create a network error (status 0). Timeout aborts get a clearer message.
66
89
  */
67
90
  export function makeNetworkError(originalError, url, method) {
68
- const timedOut = originalError?.name === 'TimeoutError' || originalError?.name === 'AbortError';
91
+ const timedOut =
92
+ originalError?.name === "TimeoutError" ||
93
+ originalError?.name === "AbortError";
69
94
  const message = timedOut
70
95
  ? `Request timed out at ${url}`
71
96
  : `Network error: ${originalError.message} at ${url}`;
@@ -95,11 +120,10 @@ export const BUSY_BACKOFF_MS = 250;
95
120
  * `attempt` must perform the whole request, not just await a prepared one:
96
121
  * a fetch body and an AbortSignal.timeout are both single-use.
97
122
  */
98
- export async function retryWhileBusy(attempt, {
99
- retries = BUSY_RETRIES,
100
- baseDelayMs = BUSY_BACKOFF_MS,
101
- onRetry,
102
- } = {}) {
123
+ export async function retryWhileBusy(
124
+ attempt,
125
+ { retries = BUSY_RETRIES, baseDelayMs = BUSY_BACKOFF_MS, onRetry } = {},
126
+ ) {
103
127
  for (let i = 0; ; i += 1) {
104
128
  try {
105
129
  return await attempt();
@@ -117,12 +141,110 @@ export async function retryWhileBusy(attempt, {
117
141
  * timeouts are disabled / unsupported.
118
142
  */
119
143
  export function timeoutSignal(timeout) {
120
- if (timeout && timeout > 0 && typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
144
+ if (
145
+ timeout &&
146
+ timeout > 0 &&
147
+ typeof AbortSignal !== "undefined" &&
148
+ AbortSignal.timeout
149
+ ) {
121
150
  return AbortSignal.timeout(timeout);
122
151
  }
123
152
  return undefined;
124
153
  }
125
154
 
155
+ /**
156
+ * Send a request through XMLHttpRequest so the caller can watch the bytes go
157
+ * up: fetch cannot report upload progress. Resolves to a Response, so the rest
158
+ * of the pipeline is the same either way. The timeout is a STALL timeout,
159
+ * re-armed by every progress event, so a large file on a slow link is never
160
+ * cut off while it is still moving, only once nothing has moved for `timeout`
161
+ * ms. Rejects the way fetch does: a TypeError for a network failure, an error
162
+ * named TimeoutError for a stall.
163
+ */
164
+ export function xhrSend(
165
+ url,
166
+ { method, headers, body },
167
+ { onUploadProgress, timeout } = {},
168
+ ) {
169
+ return new Promise((resolve, reject) => {
170
+ const xhr = new XMLHttpRequest();
171
+ xhr.open(method, url);
172
+ xhr.responseType = "arraybuffer";
173
+ for (const [name, value] of Object.entries(headers || {}))
174
+ xhr.setRequestHeader(name, value);
175
+
176
+ let timer = null;
177
+ const disarm = () => clearTimeout(timer);
178
+ const arm = () => {
179
+ if (!timeout || timeout <= 0) return;
180
+ disarm();
181
+ timer = setTimeout(() => {
182
+ xhr.abort();
183
+ reject(
184
+ Object.assign(new Error(`Request timed out at ${url}`), {
185
+ name: "TimeoutError",
186
+ }),
187
+ );
188
+ }, timeout);
189
+ };
190
+
191
+ xhr.upload.onprogress = (e) => {
192
+ arm();
193
+ if (onUploadProgress) {
194
+ onUploadProgress({
195
+ loaded: e.loaded,
196
+ total: e.lengthComputable ? e.total : null,
197
+ });
198
+ }
199
+ };
200
+ xhr.onprogress = arm;
201
+ xhr.onload = () => {
202
+ disarm();
203
+ if (xhr.status === 0) {
204
+ reject(new TypeError(`Failed to fetch ${url}`));
205
+ return;
206
+ }
207
+ const responseHeaders = new Headers();
208
+ for (const line of (xhr.getAllResponseHeaders() || "")
209
+ .trim()
210
+ .split(/\r?\n/)) {
211
+ const i = line.indexOf(":");
212
+ if (i > 0)
213
+ responseHeaders.append(
214
+ line.slice(0, i).trim(),
215
+ line.slice(i + 1).trim(),
216
+ );
217
+ }
218
+ // A Response refuses a body for these statuses.
219
+ const responseBody = [204, 205, 304].includes(xhr.status)
220
+ ? null
221
+ : xhr.response;
222
+ resolve(
223
+ new Response(responseBody, {
224
+ status: xhr.status,
225
+ statusText: xhr.statusText,
226
+ headers: responseHeaders,
227
+ }),
228
+ );
229
+ };
230
+ xhr.onerror = () => {
231
+ disarm();
232
+ reject(new TypeError(`Failed to fetch ${url}`));
233
+ };
234
+ xhr.onabort = () => {
235
+ disarm();
236
+ reject(
237
+ Object.assign(new Error(`Request aborted at ${url}`), {
238
+ name: "AbortError",
239
+ }),
240
+ );
241
+ };
242
+
243
+ arm();
244
+ xhr.send(body);
245
+ });
246
+ }
247
+
126
248
  /**
127
249
  * Generic request method handling all fetch logic.
128
250
  *
@@ -145,6 +267,11 @@ export function timeoutSignal(timeout) {
145
267
  * timeout - Per-request timeout in ms overriding client.timeout
146
268
  * for this call (0/null disables). Used for known-long
147
269
  * ops like project delete.
270
+ * onUploadProgress - Called with `{ loaded, total }` (bytes; total null when
271
+ * unknown) as the request body goes up. In a browser the
272
+ * request then travels by XMLHttpRequest (see xhrSend),
273
+ * where the timeout only fires when the upload stalls;
274
+ * elsewhere the callback is ignored and fetch is used.
148
275
  */
149
276
  export async function makeRequest(client, method, path, options = {}) {
150
277
  const {
@@ -159,6 +286,7 @@ export async function makeRequest(client, method, path, options = {}) {
159
286
  binaryResponse,
160
287
  auditMessage,
161
288
  timeout,
289
+ onUploadProgress,
162
290
  } = options;
163
291
 
164
292
  // Build URL
@@ -195,12 +323,15 @@ export async function makeRequest(client, method, path, options = {}) {
195
323
  // semantics, while stamping every op would 409 the second op against the
196
324
  // version bump the first op itself caused (every queued op captures the
197
325
  // same pre-batch version).
198
- if (client.strictModeDocumentId && method !== 'GET'
199
- && !(client.isBatching && client.batchVersionStamped)) {
326
+ if (
327
+ client.strictModeDocumentId &&
328
+ method !== "GET" &&
329
+ !(client.isBatching && client.batchVersionStamped)
330
+ ) {
200
331
  const docId = client.strictModeDocumentId;
201
332
  if (client.documentVersions[docId]) {
202
333
  const docVersion = client.documentVersions[docId];
203
- const separator = url.includes('?') ? '&' : '?';
334
+ const separator = url.includes("?") ? "&" : "?";
204
335
  url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
205
336
  if (client.isBatching) client.batchVersionStamped = true;
206
337
  }
@@ -211,19 +342,20 @@ export async function makeRequest(client, method, path, options = {}) {
211
342
  // self-conflict, so it is stamped on every queued batch op, not just the
212
343
  // first. The server templates `{param}` placeholders against the endpoint's
213
344
  // own path/query/body params.
214
- if (auditMessage && method !== 'GET') {
215
- const separator = url.includes('?') ? '&' : '?';
345
+ if (auditMessage && method !== "GET") {
346
+ const separator = url.includes("?") ? "&" : "?";
216
347
  url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
217
348
  }
218
349
 
219
350
  // Logical-operation group (see client.beginOperation): stamp every write
220
351
  // with the group id; the message rides along too so the server can label
221
352
  // the group lazily on whichever tagged write lands first.
222
- if (client.operationGroup && method !== 'GET') {
353
+ if (client.operationGroup && method !== "GET") {
223
354
  const group = client.operationGroup;
224
- const separator = url.includes('?') ? '&' : '?';
355
+ const separator = url.includes("?") ? "&" : "?";
225
356
  url += `${separator}group-id=${encodeURIComponent(group.id)}`;
226
- if (group.message) url += `&group-message=${encodeURIComponent(group.message)}`;
357
+ if (group.message)
358
+ url += `&group-message=${encodeURIComponent(group.message)}`;
227
359
  group.written = true;
228
360
  }
229
361
 
@@ -236,7 +368,7 @@ export async function makeRequest(client, method, path, options = {}) {
236
368
  throw new Error(`This endpoint cannot be used in batch mode: ${path}`);
237
369
  }
238
370
  const operation = {
239
- path: url.replace(client.baseUrl, ''),
371
+ path: url.replace(client.baseUrl, ""),
240
372
  method: method.toUpperCase(),
241
373
  };
242
374
  if (requestBody !== undefined) {
@@ -249,10 +381,10 @@ export async function makeRequest(client, method, path, options = {}) {
249
381
  // Build fetch options
250
382
  const headers = {};
251
383
  if (!noAuth) {
252
- headers['Authorization'] = `Bearer ${client.token}`;
384
+ headers["Authorization"] = `Bearer ${client.token}`;
253
385
  }
254
386
  if (!formData) {
255
- headers['Content-Type'] = 'application/json';
387
+ headers["Content-Type"] = "application/json";
256
388
  }
257
389
 
258
390
  const fetchOptions = { method, headers };
@@ -262,14 +394,21 @@ export async function makeRequest(client, method, path, options = {}) {
262
394
 
263
395
  // A fresh AbortSignal.timeout per attempt: the signal fires once and stays
264
396
  // aborted, so reusing it would make every retry fail instantly.
265
- const attemptOptions = () => {
266
- const signal = timeoutSignal(timeout !== undefined ? timeout : client.timeout);
267
- return signal ? { ...fetchOptions, signal } : fetchOptions;
397
+ const timeoutMs = timeout !== undefined ? timeout : client.timeout;
398
+ const send = () => {
399
+ if (onUploadProgress && typeof XMLHttpRequest !== "undefined") {
400
+ return xhrSend(url, fetchOptions, {
401
+ onUploadProgress,
402
+ timeout: timeoutMs,
403
+ });
404
+ }
405
+ const signal = timeoutSignal(timeoutMs);
406
+ return fetch(url, signal ? { ...fetchOptions, signal } : fetchOptions);
268
407
  };
269
408
 
270
409
  try {
271
410
  const response = await retryWhileBusy(async () => {
272
- const res = await fetch(url, attemptOptions());
411
+ const res = await send();
273
412
  // Surface a 503 as a throw so retryWhileBusy can see it; other failures
274
413
  // are re-thrown from here and handled by the caller below.
275
414
  if (res.status === 503) {
@@ -279,13 +418,26 @@ export async function makeRequest(client, method, path, options = {}) {
279
418
  });
280
419
 
281
420
  if (!response.ok) {
282
- const error = makeHttpError(response, await parseErrorBody(response), url, method);
421
+ const error = makeHttpError(
422
+ response,
423
+ await parseErrorBody(response),
424
+ url,
425
+ method,
426
+ );
283
427
  // 401 means the token is missing/expired/invalid. Fire the app's auth-error
284
428
  // handler once (it discards the token and routes back to login). 403
285
429
  // (forbidden — authenticated but not permitted) deliberately does NOT.
286
- if (response.status === 401 && typeof client.onAuthError === 'function' && !client._authErrorFired) {
430
+ if (
431
+ response.status === 401 &&
432
+ typeof client.onAuthError === "function" &&
433
+ !client._authErrorFired
434
+ ) {
287
435
  client._authErrorFired = true;
288
- try { client.onAuthError(error); } catch (_) { /* handler must not mask the original error */ }
436
+ try {
437
+ client.onAuthError(error);
438
+ } catch (_) {
439
+ /* handler must not mask the original error */
440
+ }
289
441
  }
290
442
  throw error;
291
443
  }
@@ -297,10 +449,12 @@ export async function makeRequest(client, method, path, options = {}) {
297
449
  }
298
450
 
299
451
  // JSON or text response
300
- const contentType = response.headers.get('content-type');
301
- if (contentType && contentType.includes('application/json')) {
452
+ const contentType = response.headers.get("content-type");
453
+ if (contentType && contentType.includes("application/json")) {
302
454
  const data = await response.json();
303
- extractDocumentVersions(client, response.headers, data);
455
+ extractDocumentVersions(client, response.headers, data, {
456
+ historical: /[?&]as-of=/.test(url),
457
+ });
304
458
  if (skipResponseTransform) {
305
459
  return data;
306
460
  }