@larc-iu/plaid-client 0.2.0-alpha.29 → 0.2.0-alpha.30

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 (49) hide show
  1. package/package.json +3 -2
  2. package/src/apiAnnotations.d.ts +508 -0
  3. package/src/apiAnnotations.js +543 -0
  4. package/src/apiDocuments.d.ts +569 -0
  5. package/src/apiDocuments.js +551 -0
  6. package/src/apiLayers.d.ts +369 -0
  7. package/src/apiLayers.js +222 -0
  8. package/src/apiMessages.d.ts +293 -0
  9. package/src/apiMessages.js +265 -0
  10. package/src/apiPeople.d.ts +626 -0
  11. package/src/apiPeople.js +698 -0
  12. package/src/apiProject.d.ts +761 -0
  13. package/src/apiProject.js +774 -0
  14. package/src/apiServer.d.ts +455 -0
  15. package/src/apiServer.js +422 -0
  16. package/src/apiVocab.d.ts +456 -0
  17. package/src/apiVocab.js +425 -0
  18. package/src/batch.d.ts +54 -0
  19. package/src/batch.js +323 -0
  20. package/src/client.d.ts +1321 -0
  21. package/src/client.js +743 -0
  22. package/src/constraints.d.ts +8 -0
  23. package/src/constraints.js +14 -0
  24. package/src/documentLock.d.ts +18 -4
  25. package/src/documentLock.js +43 -9
  26. package/src/entryForms.d.ts +63 -0
  27. package/src/entryForms.js +152 -0
  28. package/src/events.js +2 -1
  29. package/src/families.d.ts +349 -0
  30. package/src/families.js +396 -0
  31. package/src/http.d.ts +39 -18
  32. package/src/http.js +127 -87
  33. package/src/index.d.ts +90 -4557
  34. package/src/index.js +113 -5172
  35. package/src/operations.d.ts +36 -0
  36. package/src/operations.js +164 -0
  37. package/src/provenance.d.ts +47 -4
  38. package/src/provenance.js +168 -12
  39. package/src/roles.d.ts +39 -0
  40. package/src/roles.js +44 -0
  41. package/src/serviceSchema.d.ts +19 -13
  42. package/src/serviceSchema.js +30 -15
  43. package/src/services.d.ts +20 -3
  44. package/src/services.js +45 -8
  45. package/src/sse.js +10 -1
  46. package/src/testing.d.ts +128 -0
  47. package/src/testing.js +379 -0
  48. package/src/types.d.ts +867 -0
  49. package/src/types.js +663 -0
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
+ }