@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.
- package/index.d.ts +262 -14
- package/package.json +1 -1
- package/src/batchRef.js +128 -0
- package/src/constraints.js +84 -0
- package/src/documentLock.js +67 -29
- package/src/events.js +21 -11
- package/src/http.js +249 -41
- package/src/ids.js +54 -0
- package/src/index.js +540 -135
- package/src/replayed.js +27 -0
- package/src/roles.js +17 -0
- package/src/services.js +49 -1
- package/src/textEdits.js +191 -0
- package/src/transforms.js +4 -0
package/src/batchRef.js
ADDED
|
@@ -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
|
+
}
|
package/src/documentLock.js
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
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 ??
|
|
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
|
|
15
|
-
// from the project (`config.plaid.research.telemetry`)
|
|
16
|
-
// FLAG_TTL_MS
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 =
|
|
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
|
|
189
|
-
//
|
|
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
|
|
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
|
|
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
|
|
222
|
+
return state;
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
_send(projectId, events, keepalive) {
|