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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.16",
3
+ "version": "0.2.0-alpha.18",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
package/src/created.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The ids a create answered with. A create's response is `{id}` and a bulk
3
+ * create's is `{ids}`, read straight off the call or off its result in a
4
+ * batch (`{status, body}`). Every reader of a create response goes through
5
+ * these, so none guesses at the shape.
6
+ */
7
+
8
+ const isId = (v) => typeof v === "string";
9
+
10
+ /**
11
+ * The id a single create answered with, or undefined when it gave none.
12
+ * @param {Object|null|undefined} result - the create's response, or its batch result
13
+ * @returns {string|undefined}
14
+ */
15
+ export const createdId = (result) => {
16
+ if (isId(result?.id)) return result.id;
17
+ return isId(result?.body?.id) ? result.body.id : undefined;
18
+ };
19
+
20
+ /**
21
+ * The ids a bulk create answered with, in input order, or an empty array
22
+ * when it gave none.
23
+ * @param {Object|null|undefined} result - the bulk create's response, or its batch result
24
+ * @returns {string[]}
25
+ */
26
+ export const createdIds = (result) => {
27
+ if (Array.isArray(result?.ids)) return result.ids;
28
+ return Array.isArray(result?.body?.ids) ? result.body.ids : [];
29
+ };
@@ -10,8 +10,9 @@
10
10
  * silence, a person's edit lands between the read the work was planned from and
11
11
  * the write about to go out, and the write clobbers it.
12
12
  *
13
- * A `locked()` block therefore renews on a timer of its own. The renewal is a
14
- * plain acquire: the server refreshes a lock whose holder asks for it again.
13
+ * A `locked()` block therefore renews on a timer of its own. The renewal is an
14
+ * acquire that names the holder's `lockId`: the server refreshes the lock that
15
+ * id holds, and refuses one somebody else holds (the same user included).
15
16
  *
16
17
  * The beat is unconditional rather than "only when no write has gone out
17
18
  * recently". The request layer knows the method of a call but not which
@@ -157,9 +158,10 @@ export class LockKeeper {
157
158
  try {
158
159
  await this._refresh(this._documentId);
159
160
  } catch (error) {
160
- // 423 is definitive: somebody else holds the document now, so ours had
161
- // already expired. Anything else may be a blip, and is only fatal once
162
- // the lock we are renewing has actually run out.
161
+ // 423 is definitive: somebody else holds the document now, or ours
162
+ // expired or was dropped, and the server never takes it back.
163
+ // Anything else may be a blip, and is only fatal once the lock we are
164
+ // renewing has actually run out.
163
165
  if (error?.status === 423 || this._clock() >= deadline) {
164
166
  this._fail(error);
165
167
  return;
@@ -203,8 +205,10 @@ export class LockKeeper {
203
205
  * between steps of work that has not written anything yet.
204
206
  */
205
207
  export class DocumentLock {
206
- constructor(documentId, keeper) {
208
+ constructor(documentId, keeper, lockId = null) {
207
209
  this.documentId = documentId;
210
+ /** The holder id the acquire answered with. */
211
+ this.lockId = lockId;
208
212
  this._keeper = keeper;
209
213
  }
210
214
 
@@ -255,12 +259,13 @@ export async function withDocumentLock(
255
259
  throw error;
256
260
  }
257
261
 
262
+ const lockId = info?.lockId ?? null;
258
263
  let keeper = null;
259
264
  if (keepAlive) {
260
265
  const ttlMs = lockTtlMs(info?.expiresAt, Date.now());
261
266
  client.documentLockLost = null;
262
267
  keeper = new LockKeeper(
263
- (id) => client.documents.acquireLock(id),
268
+ (id) => client.documents.renewLock(id, lockId),
264
269
  documentId,
265
270
  ttlMs,
266
271
  {
@@ -275,7 +280,7 @@ export async function withDocumentLock(
275
280
  let result;
276
281
  let threw = false;
277
282
  try {
278
- result = await fn(new DocumentLock(documentId, keeper));
283
+ result = await fn(new DocumentLock(documentId, keeper, lockId));
279
284
  } catch (error) {
280
285
  threw = true;
281
286
  throw error;
@@ -286,7 +291,7 @@ export async function withDocumentLock(
286
291
  // Best-effort release: the server TTL reclaims a stranded lock, and we must
287
292
  // not let a release failure mask the real error from the body.
288
293
  try {
289
- await client.documents.releaseLock(documentId);
294
+ await client.documents.releaseLock(documentId, lockId);
290
295
  } catch {
291
296
  /* the lock expires on its own */
292
297
  }
package/src/events.js ADDED
@@ -0,0 +1,256 @@
1
+ import { transformRequest } from "./transforms.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Client events: the opt-in research telemetry of a project (core manual,
5
+ // "Research telemetry").
6
+ //
7
+ // `client.events.record(type, fields)` is a fire-and-forget recorder. It
8
+ // buffers events and sends them every FLUSH_INTERVAL_MS or FLUSH_AT events,
9
+ // and when the page is hidden (`pagehide`, with `keepalive` so the request
10
+ // outlives the page). A batch that fails is dropped: no retry, no toast, no
11
+ // error for the caller. Nothing it does can block or break the screen that
12
+ // called it.
13
+ //
14
+ // It sends nothing for a project whose switch is off. The switch is read once
15
+ // from the project (`config.plaid.research.telemetry`) and kept for
16
+ // FLAG_TTL_MS; `events.setEnabled` tells it at once, which is what the
17
+ // settings checkbox does. A server refusal (the switch was turned off since)
18
+ // turns it off here too.
19
+ //
20
+ // ONE recorder per server per page, whatever client records into it: an app
21
+ // makes a client per editor, and a suggestion's "shown" is deduplicated for
22
+ // the page session, not per client. The last client to record lends its
23
+ // token to the next send.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Every event type the server accepts. */
27
+ export const EVENT_TYPES = Object.freeze([
28
+ "suggestion.shown",
29
+ "suggestion.adopted",
30
+ "suggestion.dismissed",
31
+ "plan.opened",
32
+ ]);
33
+
34
+ export const FLUSH_INTERVAL_MS = 10000;
35
+ export const FLUSH_AT = 50;
36
+ export const FLAG_TTL_MS = 5 * 60 * 1000;
37
+ // Events per request. A browser refuses a `keepalive` request whose body,
38
+ // with every other keepalive request still in flight, passes 64 KiB, and a
39
+ // refused request is lost. So the pagehide send goes out in small pieces, and
40
+ // only that send is keepalive: a backlog sent while the page stays (the first
41
+ // page of a grid, drawn while the switch was still being read, can hold
42
+ // hundreds of guesses) would otherwise lose everything past the quota. The
43
+ // server takes up to 500 in one request.
44
+ const PER_REQUEST = FLUSH_AT;
45
+ // A suggested or written value is a gloss, not prose. A long one is cut, in
46
+ // code points as the server counts them, so no character is split in two.
47
+ // The server refuses a value past this length, and a key its type does not
48
+ // take (core manual, "Research telemetry").
49
+ const MAX_VALUE_CHARS = 200;
50
+
51
+ const clip = (v) => {
52
+ if (typeof v !== "string" || v.length <= MAX_VALUE_CHARS) return v;
53
+ const cps = Array.from(v);
54
+ return cps.length > MAX_VALUE_CHARS
55
+ ? cps.slice(0, MAX_VALUE_CHARS).join("")
56
+ : v;
57
+ };
58
+
59
+ export class EventRecorder {
60
+ /**
61
+ * @param {object} [opts]
62
+ * @param {function} [opts.now] - Clock, for tests
63
+ * @param {object} [opts.window] - Where `pagehide` fires (default globalThis)
64
+ */
65
+ constructor({ now = () => Date.now(), window = globalThis } = {}) {
66
+ this.client = null;
67
+ this.buffer = [];
68
+ this.timer = null;
69
+ this.now = now;
70
+ // projectId -> { state: 'on' | 'off' | 'pending', at }
71
+ this.flags = new Map();
72
+ // "shown" keys already recorded this page session.
73
+ this.seen = new Set();
74
+ this._onPageHide = () => this.flush({ keepalive: true });
75
+ this.window = window;
76
+ window?.addEventListener?.("pagehide", this._onPageHide);
77
+ }
78
+
79
+ /** Tell the recorder a project's switch, e.g. right after changing it. */
80
+ setEnabled(projectId, on) {
81
+ this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
82
+ if (!on) this._drop(projectId);
83
+ else if (this.buffer.length) this._schedule();
84
+ }
85
+
86
+ /**
87
+ * Record one event. Returns true when it was buffered, false when it was
88
+ * not (switch off, a repeat "shown", an unknown type, no project).
89
+ */
90
+ record(client, type, { projectId, documentId, targetId, data } = {}) {
91
+ try {
92
+ if (!EVENT_TYPES.includes(type) || !projectId) return false;
93
+ this.client = client;
94
+ const flag = this._flag(projectId);
95
+ if (flag === "off") return false;
96
+ // A key with no value is left out: the server takes only a string or
97
+ // a number.
98
+ const clipped = data
99
+ ? Object.fromEntries(
100
+ Object.entries(data)
101
+ .filter(([, v]) => v != null)
102
+ .map(([k, v]) => [k, clip(v)]),
103
+ )
104
+ : undefined;
105
+ let key = null;
106
+ if (type === "suggestion.shown") {
107
+ key = JSON.stringify([
108
+ projectId,
109
+ targetId ?? null,
110
+ clipped?.field ?? null,
111
+ clipped?.value ?? null,
112
+ ]);
113
+ if (this.seen.has(key)) return false;
114
+ this.seen.add(key);
115
+ }
116
+ this.buffer.push({
117
+ projectId,
118
+ key,
119
+ event: {
120
+ type,
121
+ documentId: documentId ?? undefined,
122
+ targetId: targetId == null ? undefined : String(targetId),
123
+ data: clipped,
124
+ clientTs: new Date(this.now()).toISOString(),
125
+ },
126
+ });
127
+ if (this.buffer.length >= FLUSH_AT) this.flush();
128
+ else this._schedule();
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Send what is buffered for every project whose switch is known to be on,
137
+ * drop what is buffered for one known to be off, and keep the rest until
138
+ * the switch is known. With `keepalive` (the page is going away) nothing is
139
+ * kept.
140
+ */
141
+ flush({ keepalive = false } = {}) {
142
+ clearTimeout(this.timer);
143
+ this.timer = null;
144
+ const keep = [];
145
+ const byProject = new Map();
146
+ for (const entry of this.buffer) {
147
+ const state = this.flags.get(entry.projectId)?.state;
148
+ if (state === "on") {
149
+ if (!byProject.has(entry.projectId)) byProject.set(entry.projectId, []);
150
+ byProject.get(entry.projectId).push(entry.event);
151
+ } else if (state === "off") {
152
+ if (entry.key) this.seen.delete(entry.key);
153
+ } else if (!keepalive) {
154
+ keep.push(entry);
155
+ }
156
+ }
157
+ this.buffer = keep;
158
+ for (const [projectId, events] of byProject) {
159
+ for (let i = 0; i < events.length; i += PER_REQUEST) {
160
+ this._send(projectId, events.slice(i, i + PER_REQUEST), keepalive);
161
+ }
162
+ }
163
+ }
164
+
165
+ /** Stop listening for `pagehide` and forget everything (tests). */
166
+ close() {
167
+ clearTimeout(this.timer);
168
+ this.timer = null;
169
+ this.buffer = [];
170
+ this.window?.removeEventListener?.("pagehide", this._onPageHide);
171
+ }
172
+
173
+ _schedule() {
174
+ if (this.timer == null) {
175
+ this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
176
+ }
177
+ }
178
+
179
+ _drop(projectId) {
180
+ this.buffer = this.buffer.filter((e) => {
181
+ if (e.projectId !== projectId) return true;
182
+ if (e.key) this.seen.delete(e.key);
183
+ return false;
184
+ });
185
+ }
186
+
187
+ // The switch as known now: 'on', 'off' or 'pending'. An unknown one sets
188
+ // off a read of the project, and a stale one a fresh read while the old
189
+ // answer stands. The answer flushes or drops what is buffered.
190
+ _flag(projectId) {
191
+ const known = this.flags.get(projectId);
192
+ const fresh = known && this.now() - known.at < FLAG_TTL_MS;
193
+ if (known && (fresh || known.checking)) return known.state;
194
+ this.flags.set(projectId, {
195
+ state: known ? known.state : "pending",
196
+ at: known ? known.at : 0,
197
+ checking: true,
198
+ });
199
+ const client = this.client;
200
+ Promise.resolve()
201
+ .then(() => client.projects.get(projectId))
202
+ .then(
203
+ (project) => project?.config?.plaid?.research?.telemetry === true,
204
+ () => false,
205
+ )
206
+ .then((on) => {
207
+ this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
208
+ if (!on) this._drop(projectId);
209
+ else if (this.buffer.length >= FLUSH_AT) this.flush();
210
+ else if (this.buffer.length) this._schedule();
211
+ });
212
+ return known ? known.state : "pending";
213
+ }
214
+
215
+ _send(projectId, events, keepalive) {
216
+ const client = this.client;
217
+ try {
218
+ const url = `${client.baseUrl}/api/v1/projects/${projectId}/events`;
219
+ fetch(url, {
220
+ method: "POST",
221
+ keepalive,
222
+ headers: {
223
+ Authorization: `Bearer ${client.token}`,
224
+ "Content-Type": "application/json",
225
+ },
226
+ body: JSON.stringify(transformRequest(events)),
227
+ }).then(
228
+ (res) => {
229
+ // The switch was turned off since it was read.
230
+ if (res.status === 403) this.setEnabled(projectId, false);
231
+ },
232
+ () => {},
233
+ );
234
+ } catch {
235
+ /* dropped, as every failed batch is */
236
+ }
237
+ }
238
+ }
239
+
240
+ const recorders = new Map();
241
+
242
+ /** The page's recorder for `client`'s server. */
243
+ export function recorderFor(client) {
244
+ let r = recorders.get(client.baseUrl);
245
+ if (!r) {
246
+ r = new EventRecorder();
247
+ recorders.set(client.baseUrl, r);
248
+ }
249
+ return r;
250
+ }
251
+
252
+ /** Forget every recorder (tests). */
253
+ export function resetEventRecorders() {
254
+ for (const r of recorders.values()) r.close();
255
+ recorders.clear();
256
+ }