@larc-iu/plaid-client 0.2.0-alpha.13 → 0.2.0-alpha.16

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/pagination.js CHANGED
@@ -10,6 +10,10 @@
10
10
  * Each helper threads the client through to `client._request`, so auth
11
11
  * headers, base URL, and response transforms all apply exactly as for any
12
12
  * other request.
13
+ *
14
+ * A page is a read, so it goes over the wire even when the helper is handed a
15
+ * batch (see the note at the top of http.js): each page is answered from the
16
+ * pool, in order, against the state no batch has committed yet.
13
17
  */
14
18
 
15
19
  // Merge caller query params with paging params, dropping undefined/null so we
@@ -21,16 +25,23 @@ function buildQueryParams(query, limit, cursor) {
21
25
  return params;
22
26
  }
23
27
 
28
+ // Every collection endpoint answers with the envelope. A bare array or any
29
+ // other shape is a broken endpoint, so name it and stop rather than return a
30
+ // silently truncated result.
31
+ function entriesOf(response, path) {
32
+ if (!response || !Array.isArray(response.entries)) {
33
+ throw new Error(
34
+ `GET ${path} did not return a paginated envelope (no 'entries' array)`,
35
+ );
36
+ }
37
+ return response.entries;
38
+ }
39
+
24
40
  /**
25
41
  * Fetch every page and return the full flat array of entries, transparently
26
42
  * following `nextCursor` until it is null. This is what `.list()` calls so it
27
43
  * stays backward compatible with the pre-pagination (bare-array) contract.
28
44
  *
29
- * NOTE: This auto-paginates and therefore CANNOT be used inside a batch — each
30
- * page's request needs the previous page's `nextCursor`, which doesn't exist
31
- * until the batch executes. It throws immediately when `client.isBatching` is
32
- * true. Use `listPage` for a single page inside a batch.
33
- *
34
45
  * @param {object} client - PlaidClient instance
35
46
  * @param {string} path - API path, e.g. '/api/v1/projects'
36
47
  * @param {object} [opts]
@@ -39,11 +50,6 @@ function buildQueryParams(query, limit, cursor) {
39
50
  * @returns {Promise<Array>} The concatenated entries across all pages
40
51
  */
41
52
  export async function listAll(client, path, { pageSize = 1000, query = {} } = {}) {
42
- if (client.isBatching) {
43
- throw new Error(
44
- `Cannot auto-paginate ${path} inside a batch: list methods follow cursors across multiple requests, which a batch cannot do. Use listPage() for a single page inside a batch, or call list() outside the batch.`,
45
- );
46
- }
47
53
  const all = [];
48
54
  let cursor = null;
49
55
  let prevCursor;
@@ -51,19 +57,7 @@ export async function listAll(client, path, { pageSize = 1000, query = {} } = {}
51
57
  const response = await client._request('GET', path, {
52
58
  queryParams: buildQueryParams(query, pageSize, cursor),
53
59
  });
54
- // Compatibility shim: a non-paginated server (or proxy) may return a bare
55
- // array. Treat it as a terminal full result with no further paging.
56
- if (Array.isArray(response)) {
57
- all.push(...response);
58
- break;
59
- }
60
- if (response && Array.isArray(response.entries)) {
61
- all.push(...response.entries);
62
- } else {
63
- throw new Error(
64
- "Unexpected list response shape (no 'entries'); server may be incompatible.",
65
- );
66
- }
60
+ all.push(...entriesOf(response, path));
67
61
  prevCursor = cursor;
68
62
  cursor = response.nextCursor;
69
63
  // Guard against a buggy server/proxy that returns a constant non-null
@@ -98,11 +92,6 @@ export async function listPage(client, path, { limit, cursor, query = {} } = {})
98
92
  * Async generator yielding each page's entries array in turn, following
99
93
  * `nextCursor` until it is null.
100
94
  *
101
- * NOTE: This auto-paginates and therefore CANNOT be used inside a batch — each
102
- * page's request needs the previous page's `nextCursor`, which doesn't exist
103
- * until the batch executes. It throws on first iteration when
104
- * `client.isBatching` is true. Use `listPage` for a single page inside a batch.
105
- *
106
95
  * @param {object} client - PlaidClient instance
107
96
  * @param {string} path - API path
108
97
  * @param {object} [opts]
@@ -111,18 +100,13 @@ export async function listPage(client, path, { limit, cursor, query = {} } = {})
111
100
  * @yields {Array} The entries array for each page
112
101
  */
113
102
  export async function* iterPages(client, path, { pageSize = 1000, query = {} } = {}) {
114
- if (client.isBatching) {
115
- throw new Error(
116
- `Cannot auto-paginate ${path} inside a batch: list methods follow cursors across multiple requests, which a batch cannot do. Use listPage() for a single page inside a batch, or call list() outside the batch.`,
117
- );
118
- }
119
103
  let cursor = null;
120
104
  let prevCursor;
121
105
  do {
122
106
  const response = await client._request('GET', path, {
123
107
  queryParams: buildQueryParams(query, pageSize, cursor),
124
108
  });
125
- const entries = (response && Array.isArray(response.entries)) ? response.entries : [];
109
+ const entries = entriesOf(response, path);
126
110
  // Suppress the trailing empty page that the server emits when a collection's
127
111
  // size is an exact multiple of the page size (a final full page with a
128
112
  // non-null cursor, then an empty page). Still follow the cursor below.
@@ -130,7 +114,7 @@ export async function* iterPages(client, path, { pageSize = 1000, query = {} } =
130
114
  yield entries;
131
115
  }
132
116
  prevCursor = cursor;
133
- cursor = response ? response.nextCursor : null;
117
+ cursor = response.nextCursor;
134
118
  // Guard against a buggy server/proxy that returns a constant non-null
135
119
  // cursor, which would otherwise loop forever.
136
120
  if (cursor !== null && cursor !== undefined && cursor === prevCursor) {
@@ -22,10 +22,15 @@
22
22
  * { key, label, type, description?, default?, required?,
23
23
  * options?: [{value, label}], // enum / multiselect
24
24
  * min?, max?, step?, slider?, // number (slider: drag, don't type)
25
- * placeholder?, multiline? } // string
25
+ * placeholder?, multiline?, // string
26
+ * scope? } // field: 'Sentence' | 'Word' | 'Morpheme'
26
27
  * ]
27
28
  * }
28
29
  *
30
+ * A `field` parameter names one of the project's annotation fields at its
31
+ * `scope`. A UI that knows the project offers those fields to choose from; to
32
+ * everything here, and to a UI that does not, it is a string.
33
+ *
29
34
  * A parameter's `key` is a string VALUE, so it passes over the wire verbatim;
30
35
  * the UI sends `{ [param.key]: value }` in the request data. Declare each `key`
31
36
  * in your service's own convention (Python: snake_case; JS: camelCase) and read
@@ -50,6 +55,20 @@ export const TASKS = Object.freeze({
50
55
  LINK_VOCAB: "link-vocab",
51
56
  /** Propose an interlinear analysis (morpheme segmentation + glosses) for words. */
52
57
  ANALYZE: "analyze",
58
+ /**
59
+ * Draft a meaning representation graph over the sentences of a document,
60
+ * for correction by hand. The request names a `scope` ('document' or
61
+ * 'sentence'), a `sentence` number when the scope is one sentence, and
62
+ * `overwrite`. See plaid-umr's drafting services.
63
+ *
64
+ * Apart from `analyze` on purpose. A task is the CONTRACT a spot offers a
65
+ * service against, and these two share almost none of it: an analyze
66
+ * request names the three token layers plus the language, metalanguage and
67
+ * orthography, and writes morphemes and glosses. Both claimed `analyze` for
68
+ * a while, so every app offering an analyze spot offered the other app's
69
+ * services, which could not answer the request they would have been given.
70
+ */
71
+ DRAFT_GRAPH: "draft-graph",
53
72
  /** Propose a free translation for each sentence. */
54
73
  TRANSLATE: "translate",
55
74
  /**
@@ -64,6 +83,13 @@ export const TASKS = Object.freeze({
64
83
  DETECT_SPEECH: "detect-speech",
65
84
  /** A conversational assistant over a project (chat turns; see plaid-agent). */
66
85
  ASSIST: "assist",
86
+ /**
87
+ * Score one document's annotation against another document's of the same
88
+ * text (two annotators' copies), writing a report on the scored document's
89
+ * metadata and never an annotation. The request names the other document
90
+ * as `against`. See plaid-umr's AnCast service.
91
+ */
92
+ COMPARE: "compare",
67
93
  });
68
94
 
69
95
  /**
package/src/services.js CHANGED
@@ -10,12 +10,82 @@
10
10
  */
11
11
  import { transformRequest, transformResponse } from './transforms.js';
12
12
 
13
+ // An absolute URL anywhere in an error message, with the phrase that introduces
14
+ // it (`... at http://host/api/v1/spans`). Client and transport errors name the
15
+ // endpoint they called, which is the service operator's business and not the
16
+ // requester's.
17
+ const URL_IN_TEXT = /(?:\s+(?:at|for url:?))?\s*\b[a-zA-Z][\w+.-]*:\/\/\S+/g;
18
+
19
+ /**
20
+ * What a requester is told when an error carries no message of its own.
21
+ * Naming the JS class instead would say nothing they can act on.
22
+ */
23
+ export const UNKNOWN_FAILURE = 'The service could not finish this request.';
24
+
25
+ const redact = (text, secrets) => {
26
+ let out = text;
27
+ for (const secret of secrets || []) {
28
+ if (secret && String(secret).length >= 8) out = out.split(String(secret)).join('[redacted]');
29
+ }
30
+ return out.trim();
31
+ };
32
+
33
+ /**
34
+ * One line about a failure that is safe to show the person who asked.
35
+ *
36
+ * Strips absolute URLs (so an internal host never reaches a requester's screen)
37
+ * and any `secrets` given (a model provider's API key can come back inside its
38
+ * own error text). The whole error still goes to the operator's console: this
39
+ * is the requester's half only. The Python twin is `requester_message`.
40
+ *
41
+ * @param {any} error
42
+ * @param {string[]} [secrets]
43
+ * @returns {string}
44
+ */
45
+ export function requesterMessage(error, secrets = []) {
46
+ if (error && typeof error.status === 'number') {
47
+ if (!error.status) return 'The Plaid server could not be reached.';
48
+ let text = String(error.message || '');
49
+ if (error.url) text = text.split(` at ${error.url}`).join('').split(error.url).join('');
50
+ text = text.trim().replace(/[\s,:;]+$/, '');
51
+ return redact(text, secrets) || `HTTP ${error.status}`;
52
+ }
53
+ // An Error is read by its message and nothing else: `String(new TypeError(''))`
54
+ // is 'TypeError', which names the class to the requester and tells them
55
+ // nothing they can act on.
56
+ const raw =
57
+ error && typeof error === 'object' && 'message' in error
58
+ ? String(error.message ?? '')
59
+ : String(error ?? '');
60
+ const text = raw.replace(URL_IN_TEXT, '').trim().replace(/[\s,:;]+$/, '');
61
+ return redact(text, secrets) || UNKNOWN_FAILURE;
62
+ }
63
+
64
+ /**
65
+ * What the requester is told when a request fails, named by the service.
66
+ *
67
+ * Every path that reports a failed request goes through here, so a raw message
68
+ * cannot reach a requester from one of them. (Python's twin additionally lets a
69
+ * `ValueError` through unprefixed, as an authored refusal; JS has no
70
+ * distinguished refusal type, so every failure is named by the service.)
71
+ *
72
+ * @param {any} error
73
+ * @param {string} [serviceName]
74
+ * @param {string[]} [secrets]
75
+ * @returns {string}
76
+ */
77
+ export function serviceErrorMessage(error, serviceName = '', secrets = []) {
78
+ const text = requesterMessage(error, secrets);
79
+ return serviceName ? `${serviceName}: ${text}` : text;
80
+ }
81
+
13
82
  /**
14
83
  * Discover the services seen on a project — a synchronous GET. Returns every
15
84
  * service ever registered on the project: currently connected ones carry
16
85
  * `online: true`; previously-seen offline ones carry `online: false` plus a
17
86
  * `lastSeenAt` stamp. Callers that need a service they can actually submit
18
- * work to should filter on `online`.
87
+ * work to should filter on `online`. Goes over the wire even while a batch is
88
+ * open on the client.
19
89
  *
20
90
  * @param {Object} client - PlaidClient instance
21
91
  * @param {string} projectId - Project UUID
@@ -36,7 +106,36 @@ export function discoverServices(client, projectId) {
36
106
  * @returns {Promise<void>}
37
107
  */
38
108
  export function discardService(client, projectId, serviceId) {
39
- return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`);
109
+ // outOfBand: the registry is not project data (see the note at the top of
110
+ // http.js).
111
+ return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`, {
112
+ outOfBand: true,
113
+ });
114
+ }
115
+
116
+ /**
117
+ * POST a progress, result or error event for an in-flight request; the server
118
+ * relays it to the waiting requester. The Python twin is `_report_event`.
119
+ *
120
+ * outOfBand: these are signals to whoever is waiting, not writes to the
121
+ * project (see the note at the top of http.js). A service may report them on
122
+ * the batch it is filling, where queueing them would hold every one back until
123
+ * submit, deliver none at all if the batch aborts, and take slots in the
124
+ * batch's results. The requester would hear nothing and wait out its idle
125
+ * timeout on work that had already finished.
126
+ *
127
+ * @param {Object} client - PlaidClient instance
128
+ * @param {string} projectId - Project UUID
129
+ * @param {string} requestId - The request id
130
+ * @param {Object} body - The event payload
131
+ * @returns {Promise<any>}
132
+ */
133
+ export function reportRequestEvent(client, projectId, requestId, body) {
134
+ return client._request(
135
+ 'POST',
136
+ `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}/events`,
137
+ { body, outOfBand: true },
138
+ );
40
139
  }
41
140
 
42
141
  /**
@@ -86,12 +185,10 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
86
185
  };
87
186
 
88
187
  const reportEvent = (requestId, body) =>
89
- client
90
- ._request('POST', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}/events`, { body })
91
- .catch((error) => {
92
- // 404 just means the requester already went away; nothing to do.
93
- console.warn('Failed to report request event:', error.message || error);
94
- });
188
+ reportRequestEvent(client, projectId, requestId, body).catch((error) => {
189
+ // 404 just means the requester already went away; nothing to do.
190
+ console.warn('Failed to report request event:', error.message || error);
191
+ });
95
192
 
96
193
  const serviceRegistration = {
97
194
  stop: () => {
@@ -184,9 +281,17 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
184
281
  data: { ...(data || {}), stopped: true },
185
282
  });
186
283
  },
284
+ // Every failure a requester is shown goes out through here, whoever
285
+ // reports it, so the scrub lives here rather than only in `settle`: a
286
+ // service that catches its own error and calls `helper.error(err)`
287
+ // would otherwise put the endpoint the client called on the requester's
288
+ // screen. Already-sanitized text passes through unchanged.
187
289
  error: (error) => {
188
290
  finished();
189
- return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
291
+ return reportEvent(requestId, {
292
+ status: 'error',
293
+ data: { error: requesterMessage(error) },
294
+ });
190
295
  },
191
296
  };
192
297
 
@@ -196,7 +301,11 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
196
301
  if (error instanceof ServiceCancelled || error?.name === 'ServiceCancelled') {
197
302
  responseHelper.stopped();
198
303
  } else {
199
- responseHelper.error(error?.message || error);
304
+ // What reaches the requester is the sanitized half: the raw message
305
+ // names the endpoint the client called, which is the operator's
306
+ // business. The console keeps the whole error.
307
+ console.error(`Service ${serviceId} failed request ${requestId}:`, error);
308
+ responseHelper.error(serviceErrorMessage(error, serviceName));
200
309
  }
201
310
  };
202
311
  try {
@@ -321,16 +430,6 @@ export function attachServiceRequest(client, projectId, requestId, timeout = 100
321
430
  );
322
431
  }
323
432
 
324
- /**
325
- * Ask the service to stop a request made earlier (by this user). The request
326
- * still ends with whatever the service then reports, on the stream of whoever
327
- * is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
328
- *
329
- * @param {Object} client - PlaidClient instance
330
- * @param {string} projectId - Project UUID
331
- * @param {string} requestId - The request id
332
- * @returns {Promise<void>}
333
- */
334
433
  /**
335
434
  * Thrown inside a handler when the requester has asked it to stop.
336
435
  *
@@ -380,8 +479,25 @@ export function createCancelScope(isCancelled) {
380
479
  };
381
480
  }
382
481
 
482
+ /**
483
+ * Ask the service to stop a request made earlier (by this user). The request
484
+ * still ends with whatever the service then reports, on the stream of whoever
485
+ * is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
486
+ *
487
+ * outOfBand: a DELETE, but not a write. It signals a running service and
488
+ * changes no project data (see the note at the top of http.js).
489
+ *
490
+ * @param {Object} client - PlaidClient instance
491
+ * @param {string} projectId - Project UUID
492
+ * @param {string} requestId - The request id
493
+ * @returns {Promise<void>}
494
+ */
383
495
  export function cancelServiceRequest(client, projectId, requestId) {
384
- return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
496
+ return client._request(
497
+ 'DELETE',
498
+ `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
499
+ { outOfBand: true },
500
+ );
385
501
  }
386
502
 
387
503
  /**