@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.20
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 +1638 -156
- package/package.json +1 -1
- package/src/batchRef.js +128 -0
- package/src/constraints.js +84 -0
- package/src/created.js +29 -0
- package/src/documentLock.js +342 -0
- package/src/events.js +266 -0
- package/src/http.js +719 -90
- package/src/ids.js +54 -0
- package/src/index.js +2781 -645
- package/src/metadataOps.js +117 -0
- package/src/pagination.js +19 -35
- package/src/provenance.js +268 -27
- package/src/replayed.js +27 -0
- package/src/requester.js +56 -0
- package/src/roles.js +26 -0
- package/src/serviceSchema.js +87 -33
- package/src/services.js +550 -54
- package/src/sse.js +60 -3
- package/src/textEdits.js +191 -0
- package/src/transforms.js +11 -1
package/src/events.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
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
|
|
15
|
+
// from the project (`config.plaid.research.telemetry`). An "on" is kept for
|
|
16
|
+
// FLAG_TTL_MS, one flush interval, and an "off" for FLAG_OFF_TTL_MS, so a
|
|
17
|
+
// switch another maintainer turns on reaches an open page within a minute,
|
|
18
|
+
// while a page whose switch is off, which records on every render of a grid,
|
|
19
|
+
// does not read the project every few seconds. An event recorded while an old
|
|
20
|
+
// "off" is being read again waits for the answer rather than being dropped.
|
|
21
|
+
// `events.setEnabled` tells it at once, which is what the settings checkbox
|
|
22
|
+
// does. A server refusal (the switch was turned off since) turns it off here
|
|
23
|
+
// too.
|
|
24
|
+
//
|
|
25
|
+
// ONE recorder per server per page, whatever client records into it: an app
|
|
26
|
+
// makes a client per editor, and a suggestion's "shown" is deduplicated for
|
|
27
|
+
// the page session, not per client. The last client to record lends its
|
|
28
|
+
// token to the next send.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Every event type the server accepts. */
|
|
32
|
+
export const EVENT_TYPES = Object.freeze([
|
|
33
|
+
"suggestion.shown",
|
|
34
|
+
"suggestion.adopted",
|
|
35
|
+
"suggestion.dismissed",
|
|
36
|
+
"plan.opened",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const FLUSH_INTERVAL_MS = 10000;
|
|
40
|
+
export const FLUSH_AT = 50;
|
|
41
|
+
export const FLAG_TTL_MS = FLUSH_INTERVAL_MS;
|
|
42
|
+
export const FLAG_OFF_TTL_MS = 60000;
|
|
43
|
+
// Events per request. A browser refuses a `keepalive` request whose body,
|
|
44
|
+
// with every other keepalive request still in flight, passes 64 KiB, and a
|
|
45
|
+
// refused request is lost. So the pagehide send goes out in small pieces, and
|
|
46
|
+
// only that send is keepalive: a backlog sent while the page stays (the first
|
|
47
|
+
// page of a grid, drawn while the switch was still being read, can hold
|
|
48
|
+
// hundreds of guesses) would otherwise lose everything past the quota. The
|
|
49
|
+
// server takes up to 500 in one request.
|
|
50
|
+
const PER_REQUEST = FLUSH_AT;
|
|
51
|
+
// A suggested or written value is a gloss, not prose. A long one is cut, in
|
|
52
|
+
// code points as the server counts them, so no character is split in two.
|
|
53
|
+
// The server refuses a value past this length, and a key its type does not
|
|
54
|
+
// take (core manual, "Research telemetry").
|
|
55
|
+
const MAX_VALUE_CHARS = 200;
|
|
56
|
+
|
|
57
|
+
const clip = (v) => {
|
|
58
|
+
if (typeof v !== "string" || v.length <= MAX_VALUE_CHARS) return v;
|
|
59
|
+
const cps = Array.from(v);
|
|
60
|
+
return cps.length > MAX_VALUE_CHARS
|
|
61
|
+
? cps.slice(0, MAX_VALUE_CHARS).join("")
|
|
62
|
+
: v;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export class EventRecorder {
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} [opts]
|
|
68
|
+
* @param {function} [opts.now] - Clock, for tests
|
|
69
|
+
* @param {object} [opts.window] - Where `pagehide` fires (default globalThis)
|
|
70
|
+
*/
|
|
71
|
+
constructor({ now = () => Date.now(), window = globalThis } = {}) {
|
|
72
|
+
this.client = null;
|
|
73
|
+
this.buffer = [];
|
|
74
|
+
this.timer = null;
|
|
75
|
+
this.now = now;
|
|
76
|
+
// projectId -> { state: 'on' | 'off' | 'pending', at }
|
|
77
|
+
this.flags = new Map();
|
|
78
|
+
// "shown" keys already recorded this page session.
|
|
79
|
+
this.seen = new Set();
|
|
80
|
+
this._onPageHide = () => this.flush({ keepalive: true });
|
|
81
|
+
this.window = window;
|
|
82
|
+
window?.addEventListener?.("pagehide", this._onPageHide);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Tell the recorder a project's switch, e.g. right after changing it. */
|
|
86
|
+
setEnabled(projectId, on) {
|
|
87
|
+
this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
|
|
88
|
+
if (!on) this._drop(projectId);
|
|
89
|
+
else if (this.buffer.length) this._schedule();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Record one event. Returns true when it was buffered, false when it was
|
|
94
|
+
* not (switch off, a repeat "shown", an unknown type, no project).
|
|
95
|
+
*/
|
|
96
|
+
record(client, type, { projectId, documentId, targetId, data } = {}) {
|
|
97
|
+
try {
|
|
98
|
+
if (!EVENT_TYPES.includes(type) || !projectId) return false;
|
|
99
|
+
this.client = client;
|
|
100
|
+
const flag = this._flag(projectId);
|
|
101
|
+
if (flag === "off") return false;
|
|
102
|
+
// A key with no value is left out: the server takes only a string or
|
|
103
|
+
// a number.
|
|
104
|
+
const clipped = data
|
|
105
|
+
? Object.fromEntries(
|
|
106
|
+
Object.entries(data)
|
|
107
|
+
.filter(([, v]) => v != null)
|
|
108
|
+
.map(([k, v]) => [k, clip(v)]),
|
|
109
|
+
)
|
|
110
|
+
: undefined;
|
|
111
|
+
let key = null;
|
|
112
|
+
if (type === "suggestion.shown") {
|
|
113
|
+
key = JSON.stringify([
|
|
114
|
+
projectId,
|
|
115
|
+
targetId ?? null,
|
|
116
|
+
clipped?.field ?? null,
|
|
117
|
+
clipped?.value ?? null,
|
|
118
|
+
]);
|
|
119
|
+
if (this.seen.has(key)) return false;
|
|
120
|
+
this.seen.add(key);
|
|
121
|
+
}
|
|
122
|
+
this.buffer.push({
|
|
123
|
+
projectId,
|
|
124
|
+
key,
|
|
125
|
+
event: {
|
|
126
|
+
type,
|
|
127
|
+
documentId: documentId ?? undefined,
|
|
128
|
+
targetId: targetId == null ? undefined : String(targetId),
|
|
129
|
+
data: clipped,
|
|
130
|
+
clientTs: new Date(this.now()).toISOString(),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
if (this.buffer.length >= FLUSH_AT) this.flush();
|
|
134
|
+
else this._schedule();
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Send what is buffered for every project whose switch is known to be on,
|
|
143
|
+
* drop what is buffered for one known to be off, and keep the rest until
|
|
144
|
+
* the switch is known. With `keepalive` (the page is going away) nothing is
|
|
145
|
+
* kept.
|
|
146
|
+
*/
|
|
147
|
+
flush({ keepalive = false } = {}) {
|
|
148
|
+
clearTimeout(this.timer);
|
|
149
|
+
this.timer = null;
|
|
150
|
+
const keep = [];
|
|
151
|
+
const byProject = new Map();
|
|
152
|
+
for (const entry of this.buffer) {
|
|
153
|
+
const state = this.flags.get(entry.projectId)?.state;
|
|
154
|
+
if (state === "on") {
|
|
155
|
+
if (!byProject.has(entry.projectId)) byProject.set(entry.projectId, []);
|
|
156
|
+
byProject.get(entry.projectId).push(entry.event);
|
|
157
|
+
} else if (state === "off") {
|
|
158
|
+
if (entry.key) this.seen.delete(entry.key);
|
|
159
|
+
} else if (!keepalive) {
|
|
160
|
+
keep.push(entry);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
this.buffer = keep;
|
|
164
|
+
for (const [projectId, events] of byProject) {
|
|
165
|
+
for (let i = 0; i < events.length; i += PER_REQUEST) {
|
|
166
|
+
this._send(projectId, events.slice(i, i + PER_REQUEST), keepalive);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Stop listening for `pagehide` and forget everything (tests). */
|
|
172
|
+
close() {
|
|
173
|
+
clearTimeout(this.timer);
|
|
174
|
+
this.timer = null;
|
|
175
|
+
this.buffer = [];
|
|
176
|
+
this.window?.removeEventListener?.("pagehide", this._onPageHide);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_schedule() {
|
|
180
|
+
if (this.timer == null) {
|
|
181
|
+
this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_drop(projectId) {
|
|
186
|
+
this.buffer = this.buffer.filter((e) => {
|
|
187
|
+
if (e.projectId !== projectId) return true;
|
|
188
|
+
if (e.key) this.seen.delete(e.key);
|
|
189
|
+
return false;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The switch as known now: 'on', 'off' or 'pending'. An unknown one sets
|
|
194
|
+
// off a read of the project, and a stale one a fresh read. While it is
|
|
195
|
+
// read, an old "on" stands and an old "off" is 'pending', so what is
|
|
196
|
+
// recorded meanwhile waits for the answer. The answer flushes or drops what
|
|
197
|
+
// is buffered.
|
|
198
|
+
_flag(projectId) {
|
|
199
|
+
const known = this.flags.get(projectId);
|
|
200
|
+
const ttl = known?.state === "on" ? FLAG_TTL_MS : FLAG_OFF_TTL_MS;
|
|
201
|
+
const fresh = known && this.now() - known.at < ttl;
|
|
202
|
+
if (known && (fresh || known.checking)) return known.state;
|
|
203
|
+
const state = known?.state === "on" ? "on" : "pending";
|
|
204
|
+
this.flags.set(projectId, {
|
|
205
|
+
state,
|
|
206
|
+
at: known ? known.at : 0,
|
|
207
|
+
checking: true,
|
|
208
|
+
});
|
|
209
|
+
const client = this.client;
|
|
210
|
+
Promise.resolve()
|
|
211
|
+
.then(() => client.projects.get(projectId))
|
|
212
|
+
.then(
|
|
213
|
+
(project) => project?.config?.plaid?.research?.telemetry === true,
|
|
214
|
+
() => false,
|
|
215
|
+
)
|
|
216
|
+
.then((on) => {
|
|
217
|
+
this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
|
|
218
|
+
if (!on) this._drop(projectId);
|
|
219
|
+
else if (this.buffer.length >= FLUSH_AT) this.flush();
|
|
220
|
+
else if (this.buffer.length) this._schedule();
|
|
221
|
+
});
|
|
222
|
+
return state;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_send(projectId, events, keepalive) {
|
|
226
|
+
const client = this.client;
|
|
227
|
+
try {
|
|
228
|
+
const url = `${client.baseUrl}/api/v1/projects/${projectId}/events`;
|
|
229
|
+
fetch(url, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
keepalive,
|
|
232
|
+
headers: {
|
|
233
|
+
Authorization: `Bearer ${client.token}`,
|
|
234
|
+
"Content-Type": "application/json",
|
|
235
|
+
},
|
|
236
|
+
body: JSON.stringify(transformRequest(events)),
|
|
237
|
+
}).then(
|
|
238
|
+
(res) => {
|
|
239
|
+
// The switch was turned off since it was read.
|
|
240
|
+
if (res.status === 403) this.setEnabled(projectId, false);
|
|
241
|
+
},
|
|
242
|
+
() => {},
|
|
243
|
+
);
|
|
244
|
+
} catch {
|
|
245
|
+
/* dropped, as every failed batch is */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const recorders = new Map();
|
|
251
|
+
|
|
252
|
+
/** The page's recorder for `client`'s server. */
|
|
253
|
+
export function recorderFor(client) {
|
|
254
|
+
let r = recorders.get(client.baseUrl);
|
|
255
|
+
if (!r) {
|
|
256
|
+
r = new EventRecorder();
|
|
257
|
+
recorders.set(client.baseUrl, r);
|
|
258
|
+
}
|
|
259
|
+
return r;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Forget every recorder (tests). */
|
|
263
|
+
export function resetEventRecorders() {
|
|
264
|
+
for (const r of recorders.values()) r.close();
|
|
265
|
+
recorders.clear();
|
|
266
|
+
}
|