@larc-iu/plaid-client 0.2.0-alpha.3 → 0.2.0-alpha.32

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.
Files changed (73) hide show
  1. package/README.md +39 -0
  2. package/package.json +13 -6
  3. package/src/apiAnnotations.d.ts +508 -0
  4. package/src/apiAnnotations.js +543 -0
  5. package/src/apiDocuments.d.ts +569 -0
  6. package/src/apiDocuments.js +551 -0
  7. package/src/apiLayers.d.ts +369 -0
  8. package/src/apiLayers.js +222 -0
  9. package/src/apiMessages.d.ts +293 -0
  10. package/src/apiMessages.js +265 -0
  11. package/src/apiPeople.d.ts +626 -0
  12. package/src/apiPeople.js +698 -0
  13. package/src/apiProject.d.ts +761 -0
  14. package/src/apiProject.js +774 -0
  15. package/src/apiServer.d.ts +457 -0
  16. package/src/apiServer.js +424 -0
  17. package/src/apiVocab.d.ts +456 -0
  18. package/src/apiVocab.js +425 -0
  19. package/src/batch.d.ts +54 -0
  20. package/src/batch.js +323 -0
  21. package/src/batchRef.d.ts +30 -0
  22. package/src/batchRef.js +128 -0
  23. package/src/client.d.ts +1321 -0
  24. package/src/client.js +743 -0
  25. package/src/codepoint.d.ts +94 -0
  26. package/src/codepoint.js +131 -1
  27. package/src/constraints.d.ts +85 -0
  28. package/src/constraints.js +112 -0
  29. package/src/created.d.ts +2 -0
  30. package/src/created.js +29 -0
  31. package/src/documentLock.d.ts +172 -0
  32. package/src/documentLock.js +384 -0
  33. package/src/entryForms.d.ts +68 -0
  34. package/src/entryForms.js +164 -0
  35. package/src/events.d.ts +60 -0
  36. package/src/events.js +271 -0
  37. package/src/families.d.ts +349 -0
  38. package/src/families.js +396 -0
  39. package/src/http.d.ts +233 -0
  40. package/src/http.js +809 -93
  41. package/src/ids.d.ts +26 -0
  42. package/src/ids.js +96 -0
  43. package/src/index.d.ts +101 -0
  44. package/src/index.js +183 -2105
  45. package/src/metadataOps.d.ts +15 -0
  46. package/src/metadataOps.js +124 -0
  47. package/src/operations.d.ts +36 -0
  48. package/src/operations.js +164 -0
  49. package/src/pagination.d.ts +49 -0
  50. package/src/pagination.js +20 -53
  51. package/src/provenance.d.ts +243 -0
  52. package/src/provenance.js +458 -36
  53. package/src/replayed.d.ts +3 -0
  54. package/src/replayed.js +31 -0
  55. package/src/requester.d.ts +60 -0
  56. package/src/requester.js +74 -0
  57. package/src/roles.d.ts +104 -0
  58. package/src/roles.js +64 -2
  59. package/src/serviceSchema.d.ts +337 -0
  60. package/src/serviceSchema.js +177 -60
  61. package/src/services.d.ts +319 -0
  62. package/src/services.js +742 -58
  63. package/src/sse.d.ts +30 -0
  64. package/src/sse.js +80 -3
  65. package/src/testing.d.ts +128 -0
  66. package/src/testing.js +379 -0
  67. package/src/textEdits.d.ts +59 -0
  68. package/src/textEdits.js +234 -0
  69. package/src/transforms.d.ts +25 -0
  70. package/src/transforms.js +11 -1
  71. package/src/types.d.ts +867 -0
  72. package/src/types.js +663 -0
  73. package/index.d.ts +0 -532
package/src/sse.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Project a parsed SSE `data:` object onto what a listener callback sees.
3
+ *
4
+ * The envelope is API surface and gets the usual `transformResponse` recasing.
5
+ * A `message` event's `data` is NOT: it is opaque application data (whatever
6
+ * `sendMessage` was handed), so its keys must survive verbatim, exactly as
7
+ * `metadata` and `config` do elsewhere. Without this, a key written
8
+ * `case-marker` reaches a JavaScript listener as `caseMarker` and a Python
9
+ * listener as `case_marker`, so the same broadcast reads differently depending
10
+ * on the reader's language.
11
+ */
12
+ export function eventPayload(eventType: any, parsed: any): any;
13
+ /**
14
+ * Create an SSE connection to the listen endpoint using fetch-based streaming.
15
+ * Automatically handles heartbeat confirmations and event parsing.
16
+ *
17
+ * @param {Object} client - PlaidClient instance
18
+ * @param {string} projectId - Project UUID
19
+ * @param {function} onEvent - Callback (eventType, data). Return true to stop.
20
+ * @param {string} [path] - Stream path under baseUrl. Defaults to the project
21
+ * /listen bus; service request channels pass their own. Only /listen emits
22
+ * `heartbeat` events needing a POST confirmation — other streams keep
23
+ * themselves alive with ignored SSE comments.
24
+ * @returns {Object} SSE connection with .close(), .getStats(), .readyState,
25
+ * and .error: once CLOSED, why (null when this side closed it). A refused
26
+ * stream's error carries the HTTP `status`, and a stream the server ended
27
+ * carries an error named `StreamClosed`.
28
+ */
29
+ export function createSSEConnection(client: any, projectId: string, onEvent: Function, path?: string): any;
30
+ export const SSE_IDLE_TIMEOUT_MS: 60000;
package/src/sse.js CHANGED
@@ -1,5 +1,22 @@
1
1
  import { transformResponse } from './transforms.js';
2
2
 
3
+ // A live SSE stream is never silent for long: the server sends a keepalive
4
+ // comment on service channels (every 25s) and a heartbeat event on the /listen
5
+ // bus (every 30s). So if NOTHING arrives for longer than this, the connection is
6
+ // dead: the server was killed without closing the socket, the network dropped
7
+ // it, or a reconnect caught the server mid-restart (port accepting, app not yet
8
+ // streaming). We must surface that as a drop so the registration supervisor in
9
+ // serve() can reopen it. Without the watchdog such a silent drop
10
+ // leaves `reader.read()` pending forever: readyState never reaches CLOSED, so
11
+ // nothing reconnects and the service never heals. Comfortably above both server
12
+ // cadences, so a healthy idle stream never trips it.
13
+ export const SSE_IDLE_TIMEOUT_MS = 60000;
14
+
15
+ // The last failure warned about per stream URL, so a stream retried every few
16
+ // seconds through an outage warns once per distinct failure rather than once
17
+ // per attempt. Cleared when the stream opens. Mirrors the Python client.
18
+ const lastWarning = new Map();
19
+
3
20
  /**
4
21
  * Project a parsed SSE `data:` object onto what a listener callback sees.
5
22
  *
@@ -19,6 +36,12 @@ export function eventPayload(eventType, parsed) {
19
36
  return payload;
20
37
  }
21
38
 
39
+ // Tell the client a stream of its own dropped or opened (a stand-in client in
40
+ // a test may not listen).
41
+ function tell(client, hook) {
42
+ try { client[hook]?.(); } catch (_) { /* the stream must not die of it */ }
43
+ }
44
+
22
45
  /**
23
46
  * Create an SSE connection to the listen endpoint using fetch-based streaming.
24
47
  * Automatically handles heartbeat confirmations and event parsing.
@@ -30,7 +53,10 @@ export function eventPayload(eventType, parsed) {
30
53
  * /listen bus; service request channels pass their own. Only /listen emits
31
54
  * `heartbeat` events needing a POST confirmation — other streams keep
32
55
  * themselves alive with ignored SSE comments.
33
- * @returns {Object} SSE connection with .close(), .getStats(), .readyState
56
+ * @returns {Object} SSE connection with .close(), .getStats(), .readyState,
57
+ * and .error: once CLOSED, why (null when this side closed it). A refused
58
+ * stream's error carries the HTTP `status`, and a stream the server ended
59
+ * carries an error named `StreamClosed`.
34
60
  */
35
61
  export function createSSEConnection(client, projectId, onEvent, path) {
36
62
  const streamPath = path || `/api/v1/projects/${projectId}/listen`;
@@ -59,13 +85,30 @@ export function createSSEConnection(client, projectId, onEvent, path) {
59
85
  }
60
86
  };
61
87
 
88
+ // Watchdog: abort the fetch if the stream goes silent for longer than the
89
+ // server's keepalive cadence, so a silently-dropped connection lands in the
90
+ // catch below (readyState CLOSED) instead of blocking on a read that will
91
+ // never resolve.
92
+ let idleTimer = null;
93
+ const clearIdleTimer = () => {
94
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
95
+ };
96
+ const armIdleTimer = () => {
97
+ clearIdleTimer();
98
+ idleTimer = setTimeout(() => {
99
+ if (!isClosed) abortController.abort(new Error('SSE stream went silent'));
100
+ }, SSE_IDLE_TIMEOUT_MS);
101
+ };
102
+
62
103
  const sseConnection = {
63
104
  readyState: 0, // CONNECTING
105
+ error: null,
64
106
  close: () => {
65
107
  if (!isClosed) {
66
108
  isClosed = true;
67
109
  isConnected = false;
68
110
  sseConnection.readyState = 2; // CLOSED
111
+ clearIdleTimer();
69
112
  abortController.abort();
70
113
  }
71
114
  },
@@ -83,6 +126,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
83
126
  (async () => {
84
127
  try {
85
128
  const url = `${client.baseUrl}${streamPath}`;
129
+ armIdleTimer();
86
130
  const response = await fetch(url, {
87
131
  method: 'GET',
88
132
  headers: {
@@ -101,11 +145,25 @@ export function createSSEConnection(client, projectId, onEvent, path) {
101
145
  // takes over.
102
146
  console.warn('Service registration rejected (409): another instance of this service is already connected; will retry');
103
147
  }
104
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
148
+ // A refusal says why in its body (`{error}`), as on every other route.
149
+ let said = null;
150
+ try {
151
+ said = await response.json();
152
+ } catch {
153
+ said = null;
154
+ }
155
+ const why = typeof said?.error === 'string' ? `: ${said.error}` : '';
156
+ const refused = new Error(`HTTP ${response.status} ${response.statusText}${why}`);
157
+ refused.status = response.status;
158
+ if (said && typeof said === 'object') refused.responseData = said;
159
+ throw refused;
105
160
  }
106
161
 
107
162
  isConnected = true;
108
163
  sseConnection.readyState = 1; // OPEN
164
+ lastWarning.delete(url);
165
+ // After a drop the server may have restarted with other limits.
166
+ tell(client, '_noteStreamOpened');
109
167
 
110
168
  const reader = response.body.getReader();
111
169
  const decoder = new TextDecoder();
@@ -120,6 +178,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
120
178
  while (true) {
121
179
  const { done, value } = await reader.read();
122
180
  if (done || isClosed) break;
181
+ armIdleTimer();
123
182
 
124
183
  buffer += decoder.decode(value, { stream: true });
125
184
  const lines = buffer.split('\n');
@@ -158,14 +217,32 @@ export function createSSEConnection(client, projectId, onEvent, path) {
158
217
  }
159
218
  }
160
219
  }
220
+ if (!isClosed) {
221
+ // The server ended the stream, for instance because the opener lost
222
+ // the right to it. Say so, so a caller can tell it from a close of
223
+ // its own.
224
+ sseConnection.error = Object.assign(new Error('The server closed the stream'), {
225
+ name: 'StreamClosed',
226
+ });
227
+ }
161
228
  } catch (error) {
229
+ if (!isClosed) sseConnection.error = error;
162
230
  if (error.name !== 'AbortError') {
163
- console.warn('SSE connection error:', error);
231
+ const url = `${client.baseUrl}${streamPath}`;
232
+ const message = String(error?.message || error);
233
+ if (lastWarning.get(url) !== message) {
234
+ lastWarning.set(url, message);
235
+ console.warn('SSE connection error:', error);
236
+ }
164
237
  }
165
238
  } finally {
239
+ // Ended without a close() of this side's own: a drop.
240
+ const dropped = !isClosed;
241
+ clearIdleTimer();
166
242
  isConnected = false;
167
243
  isClosed = true;
168
244
  sseConnection.readyState = 2; // CLOSED
245
+ if (dropped) tell(client, '_noteStreamDropped');
169
246
  }
170
247
  })();
171
248
 
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A request as `answer` is asked it: the bundle call that made it (`kind` and
3
+ * `args`), and the request as the server reads it.
4
+ * @typedef {{kind?: string, args: any[], method: string, path: string,
5
+ * query: Record<string, string>, body: any, batched: boolean}} FakeRequest
6
+ */
7
+ /**
8
+ * A round trip as `requests` holds it and `onRequest` hears it: a request,
9
+ * or a batch request with its `operations`.
10
+ * @typedef {{method: string, path: string, query: Record<string, string>, body: any,
11
+ * kind?: string, args?: any[], operations?: object[]}} RecordedRequest
12
+ */
13
+ /**
14
+ * An error as the client throws it for a refusal: `status`, and the
15
+ * server's answer as `responseData`.
16
+ * @param {number} status
17
+ * @param {object} [responseData]
18
+ * @returns {Error & {status: number, responseData: object}}
19
+ */
20
+ export function refusal(status: number, responseData?: object): Error & {
21
+ status: number;
22
+ responseData: object;
23
+ };
24
+ /**
25
+ * What a request is answered with when `answer` gives nothing: the id a
26
+ * create minted (or a new one), the ids of a bulk create, an empty page for a
27
+ * read, and `{}` for anything else.
28
+ * @param {{kind?: string, method: string, body?: any}} request
29
+ * @returns {object}
30
+ */
31
+ export function defaultAnswer({ kind, method, body }: {
32
+ kind?: string;
33
+ method: string;
34
+ body?: any;
35
+ }): object;
36
+ /**
37
+ * A real PlaidClient on a recording transport (see the module's note).
38
+ *
39
+ * `answer(request)` answers each request, `request` being `{kind, args,
40
+ * method, path, query, body, batched}`: the bundle call that made it (`kind`
41
+ * and `args`, any batch refs in them filled in, undefined for a request no
42
+ * bundle call made), and the request as the server reads it. It returns the
43
+ * answer as the client hands it back (camelCase), or a promise of it, or
44
+ * undefined for `defaultAnswer`'s. To refuse, it throws (see `refusal`). A
45
+ * refusal of any op of a batch refuses the whole batch, as the server's
46
+ * transaction does.
47
+ *
48
+ * @param {{answer?: (request: FakeRequest) => any,
49
+ * onRequest?: (request: RecordedRequest) => void, baseUrl?: string}} [options]
50
+ * @returns {PlaidClient & {calls: Array<{kind: string, args: any[]}>,
51
+ * requests: RecordedRequest[], operations: Array<{message: string, kind?: string, ref?: string}>}}
52
+ */
53
+ export function fakeClient({ answer, onRequest, baseUrl, }?: {
54
+ answer?: (request: FakeRequest) => any;
55
+ onRequest?: (request: RecordedRequest) => void;
56
+ baseUrl?: string;
57
+ }): PlaidClient & {
58
+ calls: Array<{
59
+ kind: string;
60
+ args: any[];
61
+ }>;
62
+ requests: RecordedRequest[];
63
+ operations: Array<{
64
+ message: string;
65
+ kind?: string;
66
+ ref?: string;
67
+ }>;
68
+ };
69
+ /**
70
+ * Runs `f` and gives its value and the milliseconds it took. With `warm`,
71
+ * runs it that many times first, untimed, so the engine has compiled it.
72
+ * With `runs`, times that many runs and keeps the quickest, which a pause for
73
+ * garbage collection or another process touches least. Only for an `f` that
74
+ * can run again.
75
+ * @template T
76
+ * @param {() => T} f
77
+ * @param {{warm?: number, runs?: number}} [options]
78
+ * @returns {[T, number]}
79
+ */
80
+ export function timed<T>(f: () => T, { warm, runs }?: {
81
+ warm?: number;
82
+ runs?: number;
83
+ }): [T, number];
84
+ /**
85
+ * Throws unless `bigMs` is under `factor` times `smallMs`, two cases timed on
86
+ * the same machine in the same run. A loaded runner slows both, so the bound
87
+ * holds where a fixed number of milliseconds fails. Pick the cases so that
88
+ * the fault the test guards against grows the ratio well past `factor`: a
89
+ * scan of every row per row goes from 10 to 100 when the big case is ten
90
+ * times the small one. `smallMs` counts as at least `floorMs` (default 1),
91
+ * so a case too quick to time cannot make the bound nothing.
92
+ * @param {number} factor
93
+ * @param {number} smallMs
94
+ * @param {number} bigMs
95
+ * @param {{floorMs?: number, label?: string}} [options]
96
+ * @returns {number} `bigMs`
97
+ */
98
+ export function withinFactorOf(factor: number, smallMs: number, bigMs: number, { floorMs, label }?: {
99
+ floorMs?: number;
100
+ label?: string;
101
+ }): number;
102
+ /**
103
+ * A request as `answer` is asked it: the bundle call that made it (`kind` and
104
+ * `args`), and the request as the server reads it.
105
+ */
106
+ export type FakeRequest = {
107
+ kind?: string;
108
+ args: any[];
109
+ method: string;
110
+ path: string;
111
+ query: Record<string, string>;
112
+ body: any;
113
+ batched: boolean;
114
+ };
115
+ /**
116
+ * A round trip as `requests` holds it and `onRequest` hears it: a request,
117
+ * or a batch request with its `operations`.
118
+ */
119
+ export type RecordedRequest = {
120
+ method: string;
121
+ path: string;
122
+ query: Record<string, string>;
123
+ body: any;
124
+ kind?: string;
125
+ args?: any[];
126
+ operations?: object[];
127
+ };
128
+ import { PlaidClient } from "./client.js";
package/src/testing.js ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * A PlaidClient for tests, with no server. The twin of plaid-client-py's
3
+ * `plaid_client.testing`, shipped as `@larc-iu/plaid-client/testing`.
4
+ *
5
+ * import { fakeClient } from "@larc-iu/plaid-client/testing";
6
+ *
7
+ * const client = fakeClient({
8
+ * answer: ({ kind, args }) =>
9
+ * kind === "documents.get" ? aDocument : undefined,
10
+ * });
11
+ * await editor.run(client);
12
+ * assert.deepEqual(client.calls.map((c) => c.kind), ["beginOperation", "tokens.create", "batch.submit"]);
13
+ *
14
+ * It is the real client: its bundles, its batch (the queue, the refs, the
15
+ * split past MAX_BATCH_OPS, the refusal of a write on a batch already sent and
16
+ * of a nested batch) and its logical operations (the closed list of kinds,
17
+ * nesting, the relabel sent only when a write joined) are the ones an app
18
+ * runs. Only the transport is replaced: each request is recorded and
19
+ * answered by `answer` instead of going out, so a test passes only on calls
20
+ * the real client makes.
21
+ *
22
+ * What it records, in order, under `client.calls` as `{kind, args}`:
23
+ * - each call of a bundle method (`"tokens.create"`, `"query"`) whose request
24
+ * is not a read, with the arguments it was given, when it is made (on the
25
+ * client or queued on a batch);
26
+ * - `"beginOperation"`, when an outermost logical operation begins, its args
27
+ * `[message]`, or `[message, {kind, ref}]` when either was given;
28
+ * - `"batch.submit"`, args `[]`, each time a batch is submitted.
29
+ *
30
+ * `client.requests` is each round trip as it would have gone out:
31
+ * `{method, path, query, body, kind, args}`, the body as the server reads it
32
+ * (kebab keys) and the call that made it, and one `{method: "POST", path:
33
+ * "/api/v1/batch", operations}` per batch request. `onRequest`, when given,
34
+ * hears each as it goes. `client.operations` is each outermost operation
35
+ * begun, as `{message, kind, ref}`. A batch's own `queued` lists the
36
+ * `{kind, args}` of the writes queued on it.
37
+ *
38
+ * What it does not do: keep any state. A write is answered, not applied, and
39
+ * a later read answers what `answer` says. Nor does it learn document
40
+ * versions, lose a lock, retry, or check that an id exists.
41
+ *
42
+ * `timed` and `withinFactorOf` bound a slow case against a small one timed
43
+ * in the same run, where a fixed number of milliseconds fails on a loaded
44
+ * runner. Their twins are `timed` and `within_factor_of` in
45
+ * `plaid_client.testing` and in plaid-core's `plaid.test-helpers`.
46
+ */
47
+
48
+ import { BatchRef } from "./batchRef.js";
49
+ import { PlaidClient } from "./client.js";
50
+ import { prepareRequest } from "./http.js";
51
+ import { uuidv7 } from "./ids.js";
52
+ import { applyMetadataOps } from "./metadataOps.js";
53
+
54
+ /**
55
+ * A request as `answer` is asked it: the bundle call that made it (`kind` and
56
+ * `args`), and the request as the server reads it.
57
+ * @typedef {{kind?: string, args: any[], method: string, path: string,
58
+ * query: Record<string, string>, body: any, batched: boolean}} FakeRequest
59
+ */
60
+
61
+ /**
62
+ * A round trip as `requests` holds it and `onRequest` hears it: a request,
63
+ * or a batch request with its `operations`.
64
+ * @typedef {{method: string, path: string, query: Record<string, string>, body: any,
65
+ * kind?: string, args?: any[], operations?: object[]}} RecordedRequest
66
+ */
67
+
68
+ /**
69
+ * An error as the client throws it for a refusal: `status`, and the
70
+ * server's answer as `responseData`.
71
+ * @param {number} status
72
+ * @param {object} [responseData]
73
+ * @returns {Error & {status: number, responseData: object}}
74
+ */
75
+ export function refusal(status, responseData = {}) {
76
+ const error = /** @type {any} */ (
77
+ new Error(`HTTP ${status} ${responseData.error || responseData.message || "Refused"}`)
78
+ );
79
+ error.status = status;
80
+ error.responseData = responseData;
81
+ return error;
82
+ }
83
+
84
+ // What the server refuses whoever sends it: a metadata patch, alone or as an
85
+ // entry of a bulk update, that is not a list of ops.
86
+ function checkMetadataOps(ops) {
87
+ if (!Array.isArray(ops)) throw refusal(400, { error: "metadata ops must be an array" });
88
+ applyMetadataOps({}, ops);
89
+ }
90
+
91
+ function serverChecks(kind, args) {
92
+ if (!kind) return;
93
+ if (kind.endsWith(".patchMetadata")) checkMetadataOps(args[1]);
94
+ if (kind.endsWith(".bulkUpdate")) {
95
+ for (const entry of args[0] || []) {
96
+ if (entry?.metadata !== undefined) checkMetadataOps(entry.metadata);
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * What a request is answered with when `answer` gives nothing: the id a
103
+ * create minted (or a new one), the ids of a bulk create, an empty page for a
104
+ * read, and `{}` for anything else.
105
+ * @param {{kind?: string, method: string, body?: any}} request
106
+ * @returns {object}
107
+ */
108
+ export function defaultAnswer({ kind, method, body }) {
109
+ if (kind?.endsWith(".bulkCreate") && Array.isArray(body)) {
110
+ return { ids: body.map((entry) => entry?.id ?? uuidv7()) };
111
+ }
112
+ if (/\.(create|copy|split)$/.test(kind ?? "")) return { id: body?.id ?? uuidv7() };
113
+ if (method === "GET") return { entries: [], nextCursor: null };
114
+ return {};
115
+ }
116
+
117
+ const isPlainObject = (v) => {
118
+ if (v === null || typeof v !== "object") return false;
119
+ const proto = Object.getPrototypeOf(v);
120
+ return proto === Object.prototype || proto === null;
121
+ };
122
+
123
+ // The id a ref stands for, read off the results its batch has so far.
124
+ const refId = (results, ref) => {
125
+ const body = results[ref.$ref]?.body;
126
+ return ref.index === undefined ? body?.id : body?.ids?.[ref.index];
127
+ };
128
+
129
+ // `args` with each BatchRef in it replaced by the id it stands for.
130
+ const fillArgs = (args, results) => {
131
+ const fill = (v) => {
132
+ if (v instanceof BatchRef) return refId(results, v);
133
+ if (Array.isArray(v)) return v.map(fill);
134
+ if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fill(x)]));
135
+ return v;
136
+ };
137
+ return args.map(fill);
138
+ };
139
+
140
+ // A queued op's body with the ids its `refs` name put in, as the server does.
141
+ const fillRefs = (body, refs, results) => {
142
+ if (!refs) return body;
143
+ const out = structuredClone(body);
144
+ for (const { at, op, index } of refs) {
145
+ let holder = out;
146
+ for (const key of at.slice(0, -1)) holder = holder[key];
147
+ holder[at.at(-1)] = refId(results, { $ref: op, index });
148
+ }
149
+ return out;
150
+ };
151
+
152
+ const split = (url, baseUrl) => {
153
+ const u = new URL(url.startsWith("http") ? url : `${baseUrl}${url}`);
154
+ return { path: u.pathname, query: Object.fromEntries(u.searchParams) };
155
+ };
156
+
157
+ // What goes over the wire is JSON: a value that cannot be (a BatchRef sent
158
+ // anywhere but a queued body) throws here as it would there.
159
+ const wire = (body) => (body === undefined ? undefined : JSON.parse(JSON.stringify(body)));
160
+
161
+ /**
162
+ * A real PlaidClient on a recording transport (see the module's note).
163
+ *
164
+ * `answer(request)` answers each request, `request` being `{kind, args,
165
+ * method, path, query, body, batched}`: the bundle call that made it (`kind`
166
+ * and `args`, any batch refs in them filled in, undefined for a request no
167
+ * bundle call made), and the request as the server reads it. It returns the
168
+ * answer as the client hands it back (camelCase), or a promise of it, or
169
+ * undefined for `defaultAnswer`'s. To refuse, it throws (see `refusal`). A
170
+ * refusal of any op of a batch refuses the whole batch, as the server's
171
+ * transaction does.
172
+ *
173
+ * @param {{answer?: (request: FakeRequest) => any,
174
+ * onRequest?: (request: RecordedRequest) => void, baseUrl?: string}} [options]
175
+ * @returns {PlaidClient & {calls: Array<{kind: string, args: any[]}>,
176
+ * requests: RecordedRequest[], operations: Array<{message: string, kind?: string, ref?: string}>}}
177
+ */
178
+ export function fakeClient({
179
+ answer = () => undefined,
180
+ onRequest = () => {},
181
+ baseUrl = "http://plaid.test",
182
+ } = {}) {
183
+ // A token of its own: each fake client is a login of its own, so nothing
184
+ // kept per login (a cache an app keeps by token) is shared between tests.
185
+ const token = `test-token-${uuidv7()}`;
186
+ const client = /** @type {any} */ (new PlaidClient(baseUrl, token, { retryDelaysMs: [] }));
187
+ const calls = [];
188
+ const requests = [];
189
+ const operations = [];
190
+ client.calls = calls;
191
+ client.requests = requests;
192
+ client.operations = operations;
193
+
194
+ // The bundle call being made, while its request is formed.
195
+ let current = null;
196
+ // Each queued op's call and batch, read when the batch is sent.
197
+ const callOf = new WeakMap();
198
+ // Each batch's results so far, for the refs its later ops hold.
199
+ const resultsOf = new WeakMap();
200
+
201
+ const respond = async (request) => {
202
+ serverChecks(request.kind, request.args);
203
+ const given = await answer(request);
204
+ return given === undefined ? defaultAnswer(request) : given;
205
+ };
206
+
207
+ // The call being made sees its request: it is recorded when the request
208
+ // is no read.
209
+ const seen = (method) => {
210
+ if (!current || current.recorded) return current;
211
+ current.recorded = true;
212
+ if (method !== "GET") calls.push({ kind: current.kind, args: current.args });
213
+ return current;
214
+ };
215
+
216
+ client._request = async (method, path, options = {}) => {
217
+ // What the real client refuses before anything goes (a write on a lock
218
+ // that lapsed) is refused here, and was never made.
219
+ const { url, requestBody, stampedGroup } = prepareRequest(client, method, path, options);
220
+ const call = seen(method);
221
+ const body = options.formData ? requestBody : wire(requestBody);
222
+ const request = { method, ...split(url, baseUrl), body, kind: call?.kind, args: call?.args ?? [] };
223
+ requests.push(request);
224
+ onRequest(request);
225
+ const answered = await respond({ ...request, batched: false });
226
+ // The server took it, so the operation's group exists.
227
+ if (stampedGroup) stampedGroup.written = true;
228
+ return answered;
229
+ };
230
+
231
+ client._postBatch = async (url, chunk) => {
232
+ const sent = wire(chunk);
233
+ const request = { method: "POST", ...split(url, baseUrl), body: undefined, operations: sent };
234
+ requests.push(request);
235
+ onRequest(request);
236
+ const answered = [];
237
+ for (let i = 0; i < chunk.length; i++) {
238
+ const op = chunk[i];
239
+ const { call, batch } = callOf.get(op) ?? callOf.get(op.body) ?? {};
240
+ const results = (batch && resultsOf.get(batch)) ?? [];
241
+ const body = fillRefs(sent[i].body, op.refs, answered);
242
+ const result = await respond({
243
+ kind: call?.kind,
244
+ args: call ? fillArgs(call.args, results) : [],
245
+ method: op.method,
246
+ ...split(op.path, baseUrl),
247
+ body,
248
+ batched: true,
249
+ });
250
+ const entry = { status: 200, headers: {}, body: result };
251
+ answered.push(entry);
252
+ results.push(entry);
253
+ }
254
+ return answered;
255
+ };
256
+
257
+ // Each bundle method on `view` (the client, or a batch) tells the
258
+ // transport which call is being made.
259
+ const instrument = (view) => {
260
+ const queued = [];
261
+ for (const [name, bundle] of Object.entries(view)) {
262
+ if (name === "client" || !isPlainObject(bundle)) continue;
263
+ for (const [method, fn] of Object.entries(bundle)) {
264
+ if (typeof fn !== "function") continue;
265
+ bundle[method] = tracked(view, `${name}.${method}`, fn, queued);
266
+ }
267
+ }
268
+ if (typeof view.query === "function") view.query = tracked(view, "query", view.query, queued);
269
+ return queued;
270
+ };
271
+
272
+ function tracked(view, kind, fn, queued) {
273
+ return function (...args) {
274
+ const call = { kind, args, recorded: false };
275
+ const ops = view === client ? null : view.operations;
276
+ const before = ops?.length;
277
+ const outer = current;
278
+ current = call;
279
+ try {
280
+ return fn.apply(this, args);
281
+ } finally {
282
+ current = outer;
283
+ // A write the call queued on a batch.
284
+ if (ops && ops.length > before) {
285
+ if (!call.recorded) {
286
+ call.recorded = true;
287
+ calls.push({ kind, args });
288
+ }
289
+ queued.push({ kind, args });
290
+ for (const op of ops.slice(before)) {
291
+ callOf.set(op, { call, batch: view });
292
+ if (op.body && typeof op.body === "object") callOf.set(op.body, { call, batch: view });
293
+ }
294
+ }
295
+ }
296
+ };
297
+ }
298
+
299
+ instrument(client);
300
+
301
+ client.batch = (options) => {
302
+ const b = PlaidClient.prototype.batch.call(client, options);
303
+ resultsOf.set(b, []);
304
+ b.queued = instrument(b);
305
+ const submit = b.submit;
306
+ b.submit = () => {
307
+ calls.push({ kind: "batch.submit", args: [] });
308
+ return submit();
309
+ };
310
+ return b;
311
+ };
312
+
313
+ client.beginOperation = function (message, opts = {}) {
314
+ if (!this.operationGroup) {
315
+ const tags = {
316
+ ...(opts.kind ? { kind: opts.kind } : {}),
317
+ ...(opts.ref ? { ref: opts.ref } : {}),
318
+ };
319
+ const id = PlaidClient.prototype.beginOperation.call(this, message, opts);
320
+ calls.push({
321
+ kind: "beginOperation",
322
+ args: Object.keys(tags).length ? [message, tags] : [message],
323
+ });
324
+ operations.push({ message, kind: opts.kind, ref: opts.ref });
325
+ return id;
326
+ }
327
+ return PlaidClient.prototype.beginOperation.call(this, message, opts);
328
+ };
329
+
330
+ return client;
331
+ }
332
+
333
+ /**
334
+ * Runs `f` and gives its value and the milliseconds it took. With `warm`,
335
+ * runs it that many times first, untimed, so the engine has compiled it.
336
+ * With `runs`, times that many runs and keeps the quickest, which a pause for
337
+ * garbage collection or another process touches least. Only for an `f` that
338
+ * can run again.
339
+ * @template T
340
+ * @param {() => T} f
341
+ * @param {{warm?: number, runs?: number}} [options]
342
+ * @returns {[T, number]}
343
+ */
344
+ export function timed(f, { warm = 0, runs = 1 } = {}) {
345
+ for (let i = 0; i < warm; i++) f();
346
+ /** @type {[T, number] | null} */
347
+ let best = null;
348
+ for (let i = 0; i < runs; i++) {
349
+ const t = performance.now();
350
+ const value = f();
351
+ const ms = performance.now() - t;
352
+ if (!best || ms < best[1]) best = [value, ms];
353
+ }
354
+ return /** @type {[T, number]} */ (best);
355
+ }
356
+
357
+ /**
358
+ * Throws unless `bigMs` is under `factor` times `smallMs`, two cases timed on
359
+ * the same machine in the same run. A loaded runner slows both, so the bound
360
+ * holds where a fixed number of milliseconds fails. Pick the cases so that
361
+ * the fault the test guards against grows the ratio well past `factor`: a
362
+ * scan of every row per row goes from 10 to 100 when the big case is ten
363
+ * times the small one. `smallMs` counts as at least `floorMs` (default 1),
364
+ * so a case too quick to time cannot make the bound nothing.
365
+ * @param {number} factor
366
+ * @param {number} smallMs
367
+ * @param {number} bigMs
368
+ * @param {{floorMs?: number, label?: string}} [options]
369
+ * @returns {number} `bigMs`
370
+ */
371
+ export function withinFactorOf(factor, smallMs, bigMs, { floorMs = 1, label } = {}) {
372
+ if (!(bigMs < factor * Math.max(smallMs, floorMs))) {
373
+ throw new Error(
374
+ `${label ? `${label}: ` : ""}${Math.round(bigMs)} ms, not under ${factor} times ` +
375
+ `the small case's ${Math.round(smallMs)} ms`,
376
+ );
377
+ }
378
+ return bigMs;
379
+ }