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

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,11 @@
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 every request these helpers make carries `bypassBatch`
15
+ * (see the three classes at the top of http.js). That is what lets them work
16
+ * while a batch is open on the same client: each page is answered from the
17
+ * pool, in order, against the state the batch has not committed yet.
13
18
  */
14
19
 
15
20
  // Merge caller query params with paging params, dropping undefined/null so we
@@ -21,16 +26,23 @@ function buildQueryParams(query, limit, cursor) {
21
26
  return params;
22
27
  }
23
28
 
29
+ // Every collection endpoint answers with the envelope. A bare array or any
30
+ // other shape is a broken endpoint, so name it and stop rather than return a
31
+ // silently truncated result.
32
+ function entriesOf(response, path) {
33
+ if (!response || !Array.isArray(response.entries)) {
34
+ throw new Error(
35
+ `GET ${path} did not return a paginated envelope (no 'entries' array)`,
36
+ );
37
+ }
38
+ return response.entries;
39
+ }
40
+
24
41
  /**
25
42
  * Fetch every page and return the full flat array of entries, transparently
26
43
  * following `nextCursor` until it is null. This is what `.list()` calls so it
27
44
  * stays backward compatible with the pre-pagination (bare-array) contract.
28
45
  *
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
46
  * @param {object} client - PlaidClient instance
35
47
  * @param {string} path - API path, e.g. '/api/v1/projects'
36
48
  * @param {object} [opts]
@@ -39,31 +51,15 @@ function buildQueryParams(query, limit, cursor) {
39
51
  * @returns {Promise<Array>} The concatenated entries across all pages
40
52
  */
41
53
  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
54
  const all = [];
48
55
  let cursor = null;
49
56
  let prevCursor;
50
57
  do {
51
58
  const response = await client._request('GET', path, {
52
59
  queryParams: buildQueryParams(query, pageSize, cursor),
60
+ bypassBatch: true,
53
61
  });
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
- }
62
+ all.push(...entriesOf(response, path));
67
63
  prevCursor = cursor;
68
64
  cursor = response.nextCursor;
69
65
  // Guard against a buggy server/proxy that returns a constant non-null
@@ -91,6 +87,7 @@ export async function listAll(client, path, { pageSize = 1000, query = {} } = {}
91
87
  export async function listPage(client, path, { limit, cursor, query = {} } = {}) {
92
88
  return client._request('GET', path, {
93
89
  queryParams: buildQueryParams(query, limit, cursor),
90
+ bypassBatch: true,
94
91
  });
95
92
  }
96
93
 
@@ -98,11 +95,6 @@ export async function listPage(client, path, { limit, cursor, query = {} } = {})
98
95
  * Async generator yielding each page's entries array in turn, following
99
96
  * `nextCursor` until it is null.
100
97
  *
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
98
  * @param {object} client - PlaidClient instance
107
99
  * @param {string} path - API path
108
100
  * @param {object} [opts]
@@ -111,18 +103,14 @@ export async function listPage(client, path, { limit, cursor, query = {} } = {})
111
103
  * @yields {Array} The entries array for each page
112
104
  */
113
105
  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
106
  let cursor = null;
120
107
  let prevCursor;
121
108
  do {
122
109
  const response = await client._request('GET', path, {
123
110
  queryParams: buildQueryParams(query, pageSize, cursor),
111
+ bypassBatch: true,
124
112
  });
125
- const entries = (response && Array.isArray(response.entries)) ? response.entries : [];
113
+ const entries = entriesOf(response, path);
126
114
  // Suppress the trailing empty page that the server emits when a collection's
127
115
  // size is an exact multiple of the page size (a final full page with a
128
116
  // non-null cursor, then an empty page). Still follow the cursor below.
@@ -130,7 +118,7 @@ export async function* iterPages(client, path, { pageSize = 1000, query = {} } =
130
118
  yield entries;
131
119
  }
132
120
  prevCursor = cursor;
133
- cursor = response ? response.nextCursor : null;
121
+ cursor = response.nextCursor;
134
122
  // Guard against a buggy server/proxy that returns a constant non-null
135
123
  // cursor, which would otherwise loop forever.
136
124
  if (cursor !== null && cursor !== undefined && cursor === prevCursor) {
package/src/services.js CHANGED
@@ -10,19 +10,96 @@
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
22
92
  * @returns {Promise<Array>} [{serviceId, serviceName, description, extras, online, lastSeenAt}]
23
93
  */
24
94
  export function discoverServices(client, projectId) {
25
- return client._request('GET', `/api/v1/projects/${projectId}/services`);
95
+ // bypassBatch: the registry is read by whoever asked, not by whatever batch
96
+ // happens to be open on this shared client. The assistant's availability
97
+ // probe polls for 49 seconds after a mount and landed inside a Grew rewrite
98
+ // or an import, where the answer was `{batched: true}`, cached as the
99
+ // service list, and every later render threw on it until a reload.
100
+ return client._request('GET', `/api/v1/projects/${projectId}/services`, {
101
+ bypassBatch: true,
102
+ });
26
103
  }
27
104
 
28
105
  /**
@@ -39,6 +116,31 @@ export function discardService(client, projectId, serviceId) {
39
116
  return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`);
40
117
  }
41
118
 
119
+ /**
120
+ * POST a progress, result or error event for an in-flight request; the server
121
+ * relays it to the waiting requester. The Python twin is `_report_event`.
122
+ *
123
+ * bypassBatch: these are out-of-band signals to whoever is waiting, not writes
124
+ * to the project. A service reports them from inside its own
125
+ * `client.batched()` block, where queueing them would hold every one back
126
+ * until submit, deliver none at all if the batch aborts, and take slots in the
127
+ * batch's results. The requester would hear nothing and wait out its idle
128
+ * timeout on work that had already finished.
129
+ *
130
+ * @param {Object} client - PlaidClient instance
131
+ * @param {string} projectId - Project UUID
132
+ * @param {string} requestId - The request id
133
+ * @param {Object} body - The event payload
134
+ * @returns {Promise<any>}
135
+ */
136
+ export function reportRequestEvent(client, projectId, requestId, body) {
137
+ return client._request(
138
+ 'POST',
139
+ `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}/events`,
140
+ { body, bypassBatch: true },
141
+ );
142
+ }
143
+
42
144
  /**
43
145
  * Register a service and handle incoming work requests.
44
146
  *
@@ -86,12 +188,10 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
86
188
  };
87
189
 
88
190
  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
- });
191
+ reportRequestEvent(client, projectId, requestId, body).catch((error) => {
192
+ // 404 just means the requester already went away; nothing to do.
193
+ console.warn('Failed to report request event:', error.message || error);
194
+ });
95
195
 
96
196
  const serviceRegistration = {
97
197
  stop: () => {
@@ -184,9 +284,17 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
184
284
  data: { ...(data || {}), stopped: true },
185
285
  });
186
286
  },
287
+ // Every failure a requester is shown goes out through here, whoever
288
+ // reports it, so the scrub lives here rather than only in `settle`: a
289
+ // service that catches its own error and calls `helper.error(err)`
290
+ // would otherwise put the endpoint the client called on the requester's
291
+ // screen. Already-sanitized text passes through unchanged.
187
292
  error: (error) => {
188
293
  finished();
189
- return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
294
+ return reportEvent(requestId, {
295
+ status: 'error',
296
+ data: { error: requesterMessage(error) },
297
+ });
190
298
  },
191
299
  };
192
300
 
@@ -196,7 +304,11 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
196
304
  if (error instanceof ServiceCancelled || error?.name === 'ServiceCancelled') {
197
305
  responseHelper.stopped();
198
306
  } else {
199
- responseHelper.error(error?.message || error);
307
+ // What reaches the requester is the sanitized half: the raw message
308
+ // names the endpoint the client called, which is the operator's
309
+ // business. The console keeps the whole error.
310
+ console.error(`Service ${serviceId} failed request ${requestId}:`, error);
311
+ responseHelper.error(serviceErrorMessage(error, serviceName));
200
312
  }
201
313
  };
202
314
  try {
@@ -321,16 +433,6 @@ export function attachServiceRequest(client, projectId, requestId, timeout = 100
321
433
  );
322
434
  }
323
435
 
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
436
  /**
335
437
  * Thrown inside a handler when the requester has asked it to stop.
336
438
  *
@@ -380,8 +482,30 @@ export function createCancelScope(isCancelled) {
380
482
  };
381
483
  }
382
484
 
485
+ /**
486
+ * Ask the service to stop a request made earlier (by this user). The request
487
+ * still ends with whatever the service then reports, on the stream of whoever
488
+ * is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
489
+ *
490
+ * Goes over the wire even while a batch is open on the client. A DELETE, but
491
+ * not a write: it signals a running service out of band and changes no project
492
+ * data. The Stop button lives in the app's chrome, on the same client an
493
+ * import or a bulk edit is batching on, and a stop that waits for that batch to
494
+ * submit has stopped nothing (and if the batch aborts, it never arrives at
495
+ * all), while the queued DELETE shifts every result index the batch's caller
496
+ * reads back.
497
+ *
498
+ * @param {Object} client - PlaidClient instance
499
+ * @param {string} projectId - Project UUID
500
+ * @param {string} requestId - The request id
501
+ * @returns {Promise<void>}
502
+ */
383
503
  export function cancelServiceRequest(client, projectId, requestId) {
384
- return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
504
+ return client._request(
505
+ 'DELETE',
506
+ `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
507
+ { bypassBatch: true },
508
+ );
385
509
  }
386
510
 
387
511
  /**