@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/index.d.ts +409 -65
- package/package.json +1 -1
- package/src/created.js +29 -0
- package/src/documentLock.js +14 -9
- package/src/events.js +256 -0
- package/src/http.js +168 -56
- package/src/index.js +495 -208
- package/src/metadataOps.js +117 -0
- package/src/provenance.js +7 -17
- package/src/requester.js +56 -0
- package/src/services.js +62 -7
- package/src/sse.js +29 -3
- package/src/transforms.js +7 -1
package/package.json
CHANGED
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
|
+
};
|
package/src/documentLock.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
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,
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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.
|
|
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
|
+
}
|