@larc-iu/plaid-client 0.2.0-alpha.18 → 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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Batch refs: a write in a batch that uses the id an earlier write in the
3
+ * same batch creates.
4
+ *
5
+ * `b.ref()` returns a BatchRef, which the caller puts in a later write's
6
+ * body where the id goes. When that write is queued, each BatchRef is taken
7
+ * out of the body (null is left in its place) and recorded beside it as
8
+ * `refs: [{ at: [key or index, ...], op: n, index?: k }]`, which the server
9
+ * fills in. The server never searches a body, so user data of any shape,
10
+ * `{ $ref: 0 }` included, is stored as sent. A BatchRef is a class instance
11
+ * no JSON value can be, so data can never be taken for one here either.
12
+ */
13
+
14
+ const MISPLACED =
15
+ "b.ref() stands for an id only in the body of a later write on the same batch";
16
+
17
+ export class BatchRef {
18
+ #batch;
19
+
20
+ constructor(batch, op, index) {
21
+ this.#batch = batch;
22
+ /** The op, counted from 0 in the batch, whose id this stands for. */
23
+ this.$ref = op;
24
+ /** For a bulk create, which of its ids. */
25
+ if (index !== undefined) this.index = index;
26
+ Object.freeze(this);
27
+ }
28
+
29
+ belongsTo(batch) {
30
+ return this.#batch === batch;
31
+ }
32
+
33
+ // Sent anywhere but a queued body (a call made on the client, a path, a
34
+ // query), a ref would go out as something the server takes for data.
35
+ toJSON() {
36
+ throw new Error(MISPLACED);
37
+ }
38
+
39
+ [Symbol.toPrimitive]() {
40
+ throw new Error(MISPLACED);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * A BatchRef for op `opIndex` of `batch` (negative counts from the end, -1
46
+ * is the op queued last), fixed when it is called.
47
+ */
48
+ export function makeBatchRef(batch, opIndex, index) {
49
+ const n = opIndex < 0 ? batch.operations.length + opIndex : opIndex;
50
+ if (!Number.isInteger(n) || n < 0 || n >= batch.operations.length) {
51
+ throw new Error(`No operation ${opIndex} has been queued on this batch`);
52
+ }
53
+ if (index !== undefined && !(Number.isInteger(index) && index >= 0)) {
54
+ throw new Error(`A ref's index must be a whole number, not ${index}`);
55
+ }
56
+ return new BatchRef(batch, n, index);
57
+ }
58
+
59
+ const isPlainObject = (v) => {
60
+ if (v === null || typeof v !== "object") return false;
61
+ const proto = Object.getPrototypeOf(v);
62
+ return proto === Object.prototype || proto === null;
63
+ };
64
+
65
+ function holdsRef(v) {
66
+ if (v instanceof BatchRef) return true;
67
+ if (Array.isArray(v)) return v.some(holdsRef);
68
+ if (isPlainObject(v)) {
69
+ for (const k in v) if (holdsRef(v[k])) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * `body` with each BatchRef in it replaced by null, and the `refs` that say
76
+ * where they were, or `refs` null when it holds none (the body is then
77
+ * returned as it was).
78
+ */
79
+ export function takeRefs(batch, body) {
80
+ if (!holdsRef(body)) return { body, refs: null };
81
+ const refs = [];
82
+ const at = [];
83
+ const take = (v) => {
84
+ if (v instanceof BatchRef) {
85
+ if (!v.belongsTo(batch)) throw new Error(MISPLACED);
86
+ refs.push(
87
+ v.index === undefined
88
+ ? { at: [...at], op: v.$ref }
89
+ : { at: [...at], op: v.$ref, index: v.index },
90
+ );
91
+ return null;
92
+ }
93
+ if (!holdsRef(v)) return v;
94
+ if (Array.isArray(v)) {
95
+ return v.map((x, i) => {
96
+ at.push(i);
97
+ const out = take(x);
98
+ at.pop();
99
+ return out;
100
+ });
101
+ }
102
+ const out = {};
103
+ for (const k of Object.keys(v)) {
104
+ at.push(k);
105
+ out[k] = take(v[k]);
106
+ at.pop();
107
+ }
108
+ return out;
109
+ };
110
+ return { body: take(body), refs };
111
+ }
112
+
113
+ /**
114
+ * `refs` counted from `start`, for an op in a batch sent in several requests
115
+ * (the server counts within one request). A ref to an op of an earlier
116
+ * request cannot resolve, so it throws before anything goes.
117
+ */
118
+ export function rebaseRefs(refs, start) {
119
+ return refs.map((r) => {
120
+ if (r.op < start) {
121
+ throw new Error(
122
+ `Operation ${start} and later are sent in a request of their own, ` +
123
+ `so an operation among them cannot use the id operation ${r.op} creates`,
124
+ );
125
+ }
126
+ return { ...r, op: r.op - start };
127
+ });
128
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Layer constraints: rules an app declares on the token, span and relation
3
+ * layers it owns, which the server enforces inside every write. A write that
4
+ * breaks one on a row it writes is refused with 422 and the violations. See
5
+ * the core manual, "Layer constraints".
6
+ */
7
+
8
+ import { transformResponse } from "./transforms.js";
9
+
10
+ /** The constraint types, by the layer kind that may carry them. */
11
+ export const CONSTRAINT_TYPES = Object.freeze([
12
+ "max-in-degree",
13
+ "acyclic",
14
+ "same-ancestor",
15
+ "single-span",
16
+ "value-set",
17
+ "coextensive",
18
+ "single-link",
19
+ ]);
20
+
21
+ /**
22
+ * The violations of a write refused by a layer constraint, camelCased
23
+ * ({constraint, namespace, layer, layerName, document, at, ids, value?,
24
+ * parts?}), or null for any other error. The answer lists at most 100, and
25
+ * `err.responseData["violation-count"]` is the total.
26
+ * @param {any} err
27
+ * @returns {Array<object>|null}
28
+ */
29
+ export function violationsOf(err) {
30
+ const vs = err?.status === 422 ? err?.responseData?.violations : undefined;
31
+ return Array.isArray(vs) ? transformResponse(vs) : null;
32
+ }
33
+
34
+ /** The request body of a constraint write: the list, and `expected` when given. */
35
+ export function constraintsBody(constraints, options) {
36
+ const body = constraints === undefined ? {} : { constraints };
37
+ if (options && Object.hasOwn(options, "expected")) {
38
+ body.expected = options.expected === undefined ? null : options.expected;
39
+ }
40
+ return body;
41
+ }
42
+
43
+ // What String.prototype.trim trims. The server trims a value-set's parts the
44
+ // same way, so a value read as listed here is read so there.
45
+ const splitParts = (value, delimiters) => {
46
+ const set = new Set([...(delimiters || "")]);
47
+ if (!set.size) return [value];
48
+ const out = [];
49
+ let part = "";
50
+ for (const ch of value) {
51
+ if (set.has(ch)) {
52
+ out.push(part);
53
+ part = "";
54
+ } else {
55
+ part += ch;
56
+ }
57
+ }
58
+ out.push(part);
59
+ return out;
60
+ };
61
+
62
+ /**
63
+ * Whether a value-set constraint ({values, delimiters?, parts?}) allows
64
+ * `value` as the server reads it: null and blank values pass, a non-string
65
+ * does not, and every part split on a delimiter (or only the first, with
66
+ * `parts: "first"`), trimmed, must be listed.
67
+ * @param {{values: string[], delimiters?: string, parts?: "all"|"first"}} constraint
68
+ * @param {any} value
69
+ * @returns {boolean}
70
+ */
71
+ export function valueSetAllows(constraint, value) {
72
+ if (value == null) return true;
73
+ if (typeof value !== "string") return false;
74
+ if (value.trim() === "") return true;
75
+ const delimiters = constraint?.delimiters || "";
76
+ const values = constraint?.values || [];
77
+ const parts = splitParts(value, delimiters);
78
+ if (constraint?.parts === "first") {
79
+ const firsts = new Set(values.map((v) => splitParts(v, delimiters)[0].trim()));
80
+ return firsts.has(parts[0].trim());
81
+ }
82
+ const allowed = new Set(values);
83
+ return parts.every((p) => p.trim() !== "" && allowed.has(p.trim()));
84
+ }
@@ -73,11 +73,7 @@ export class DocumentLockLost extends Error {
73
73
  * @param {number} [fallback]
74
74
  * @returns {number}
75
75
  */
76
- export function lockTtlMs(
77
- expiresAt,
78
- nowMs,
79
- fallback = DOCUMENT_LOCK_TTL_MS,
80
- ) {
76
+ export function lockTtlMs(expiresAt, nowMs, fallback = DOCUMENT_LOCK_TTL_MS) {
81
77
  if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
82
78
  const ttl = expiresAt - nowMs;
83
79
  if (ttl > 0 && ttl <= MAX_BELIEVABLE_TTL_MS) return ttl;
@@ -222,6 +218,67 @@ export class DocumentLock {
222
218
  }
223
219
  }
224
220
 
221
+ /**
222
+ * Tries a `locked()` block makes at an acquire whose outcome is unknown, and the
223
+ * pause before the next, which grows by this much each time.
224
+ */
225
+ export const LOCK_ACQUIRE_ATTEMPTS = 3;
226
+ export const LOCK_ACQUIRE_RETRY_MS = 500;
227
+
228
+ // The HTTP status of a request that may or may not have reached the server: no
229
+ // answer at all (0), or a proxy's 502 or 504.
230
+ const UNKNOWN_OUTCOME_STATUSES = new Set([0, 502, 504]);
231
+
232
+ function mintLockId() {
233
+ return globalThis.crypto.randomUUID();
234
+ }
235
+
236
+ /**
237
+ * The acquire of a `locked()` block, under the holder id it minted.
238
+ *
239
+ * An acquire whose outcome is unknown (no answer, a timeout, a 502 or a 504)
240
+ * may have taken the lock. It is sent again under the same id, which the server
241
+ * answers 200 while that holder has it. When every attempt is unknown, the lock
242
+ * it may hold is released on the way out, so it does not stand in everyone's
243
+ * way until it expires.
244
+ */
245
+ async function takeLock(client, documentId, lockId, retryMs) {
246
+ for (let attempt = 0; ; attempt += 1) {
247
+ try {
248
+ return await client.documents.acquireLock(documentId, undefined, lockId);
249
+ } catch (error) {
250
+ if (error?.status === 423) {
251
+ // The error body is raw JSON off the wire, so it is still kebab-cased.
252
+ const body = error.responseData || {};
253
+ const holder = body.userId || body["user-id"] || "another user";
254
+ const readable = new Error(
255
+ `This document is being edited by ${holder}. Try again once they're done.`,
256
+ );
257
+ readable.status = 423;
258
+ readable.statusText = error.statusText;
259
+ readable.url = error.url;
260
+ readable.method = error.method;
261
+ readable.responseData = error.responseData;
262
+ readable.cause = error;
263
+ throw readable;
264
+ }
265
+ if (!UNKNOWN_OUTCOME_STATUSES.has(error?.status)) throw error;
266
+ if (attempt + 1 < LOCK_ACQUIRE_ATTEMPTS) {
267
+ await new Promise((resolve) =>
268
+ setTimeout(resolve, retryMs * (attempt + 1)),
269
+ );
270
+ continue;
271
+ }
272
+ try {
273
+ await client.documents.releaseLock(documentId, lockId);
274
+ } catch {
275
+ /* the lock expires on its own */
276
+ }
277
+ throw error;
278
+ }
279
+ }
280
+ }
281
+
225
282
  /**
226
283
  * Hold `documentId`'s server-enforced lock for the length of `fn`.
227
284
  * `client.documents.locked` is the entry point; see its doc comment.
@@ -229,37 +286,18 @@ export class DocumentLock {
229
286
  * @param {object} client
230
287
  * @param {string} documentId
231
288
  * @param {(lock: DocumentLock) => any} fn
232
- * @param {{ keepAlive?: boolean }} [options]
289
+ * @param {{ keepAlive?: boolean, acquireRetryMs?: number }} [options]
233
290
  */
234
291
  export async function withDocumentLock(
235
292
  client,
236
293
  documentId,
237
294
  fn,
238
- { keepAlive = true } = {},
295
+ { keepAlive = true, acquireRetryMs = LOCK_ACQUIRE_RETRY_MS } = {},
239
296
  ) {
240
- let info;
241
- try {
242
- info = await client.documents.acquireLock(documentId);
243
- } catch (error) {
244
- if (error?.status === 423) {
245
- // The error body is raw JSON off the wire, so it is still kebab-cased.
246
- const body = error.responseData || {};
247
- const holder = body.userId || body["user-id"] || "another user";
248
- const readable = new Error(
249
- `Document ${documentId} is locked by ${holder} (likely being edited); try again once they're done.`,
250
- );
251
- readable.status = 423;
252
- readable.statusText = error.statusText;
253
- readable.url = error.url;
254
- readable.method = error.method;
255
- readable.responseData = error.responseData;
256
- readable.cause = error;
257
- throw readable;
258
- }
259
- throw error;
260
- }
297
+ const minted = mintLockId();
298
+ const info = await takeLock(client, documentId, minted, acquireRetryMs);
261
299
 
262
- const lockId = info?.lockId ?? null;
300
+ const lockId = info?.lockId ?? minted;
263
301
  let keeper = null;
264
302
  if (keepAlive) {
265
303
  const ttlMs = lockTtlMs(info?.expiresAt, Date.now());
package/src/events.js CHANGED
@@ -11,11 +11,16 @@ import { transformRequest } from "./transforms.js";
11
11
  // error for the caller. Nothing it does can block or break the screen that
12
12
  // called it.
13
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.
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.
19
24
  //
20
25
  // ONE recorder per server per page, whatever client records into it: an app
21
26
  // makes a client per editor, and a suggestion's "shown" is deduplicated for
@@ -33,7 +38,8 @@ export const EVENT_TYPES = Object.freeze([
33
38
 
34
39
  export const FLUSH_INTERVAL_MS = 10000;
35
40
  export const FLUSH_AT = 50;
36
- export const FLAG_TTL_MS = 5 * 60 * 1000;
41
+ export const FLAG_TTL_MS = FLUSH_INTERVAL_MS;
42
+ export const FLAG_OFF_TTL_MS = 60000;
37
43
  // Events per request. A browser refuses a `keepalive` request whose body,
38
44
  // with every other keepalive request still in flight, passes 64 KiB, and a
39
45
  // refused request is lost. So the pagehide send goes out in small pieces, and
@@ -185,14 +191,18 @@ export class EventRecorder {
185
191
  }
186
192
 
187
193
  // 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.
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.
190
198
  _flag(projectId) {
191
199
  const known = this.flags.get(projectId);
192
- const fresh = known && this.now() - known.at < FLAG_TTL_MS;
200
+ const ttl = known?.state === "on" ? FLAG_TTL_MS : FLAG_OFF_TTL_MS;
201
+ const fresh = known && this.now() - known.at < ttl;
193
202
  if (known && (fresh || known.checking)) return known.state;
203
+ const state = known?.state === "on" ? "on" : "pending";
194
204
  this.flags.set(projectId, {
195
- state: known ? known.state : "pending",
205
+ state,
196
206
  at: known ? known.at : 0,
197
207
  checking: true,
198
208
  });
@@ -209,7 +219,7 @@ export class EventRecorder {
209
219
  else if (this.buffer.length >= FLUSH_AT) this.flush();
210
220
  else if (this.buffer.length) this._schedule();
211
221
  });
212
- return known ? known.state : "pending";
222
+ return state;
213
223
  }
214
224
 
215
225
  _send(projectId, events, keepalive) {