@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.2",
3
+ "version": "0.2.0-alpha.20",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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/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
+ };
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Keeping a document lock alive for as long as a `locked()` block runs.
3
+ *
4
+ * plaid-core expires a document lock about a minute after it was taken, and the
5
+ * holder's own writes are what renew it (`plaid.sql.operation` calls
6
+ * `refresh-locks!` on every write it accepts). That is the right rule for an
7
+ * editor, which writes as the person types. It is the wrong one for a service:
8
+ * a parser loads a model, reads the document, spends minutes in inference and
9
+ * only then writes, so for most of its run it holds nothing. The lock lapses in
10
+ * silence, a person's edit lands between the read the work was planned from and
11
+ * the write about to go out, and the write clobbers it.
12
+ *
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).
16
+ *
17
+ * The beat is unconditional rather than "only when no write has gone out
18
+ * recently". The request layer knows the method of a call but not which
19
+ * document it touched, so a write to some other document would count as a
20
+ * renewal here and would not be one. Two extra requests a minute is the whole
21
+ * cost.
22
+ *
23
+ * A renewal that fails means the block is no longer holding what it asked for,
24
+ * so it stops the run: the loss is recorded on the client and every later write
25
+ * throws `DocumentLockLost`. The guard sits in the request layer because the
26
+ * callers are services, and a check they have to remember to call is a check
27
+ * that is missing somewhere.
28
+ */
29
+
30
+ /**
31
+ * How long plaid-core holds a document lock before it expires, in ms.
32
+ * `plaid.server.locks/default-lock-expiration-ms`. An operator can change it
33
+ * with `:plaid.server.locks/config :expiration-ms`, and a server publishes what
34
+ * it enforces as `lockExpirationMs` in `GET /info`. This is the last resort:
35
+ * what a live lock is renewed against is the `expiresAt` on the acquire
36
+ * response, which names the moment rather than the window.
37
+ */
38
+ export const DOCUMENT_LOCK_TTL_MS = 60000;
39
+
40
+ // Widest lock lifetime we will believe from a server response. Past this the
41
+ // number is a clock skew between this machine and the server rather than a
42
+ // configured window, and the documented default is the better guess.
43
+ const MAX_BELIEVABLE_TTL_MS = 3600000;
44
+
45
+ /**
46
+ * The lock a `documents.locked()` block was holding is no longer held.
47
+ *
48
+ * Thrown by the keep-alive when it cannot renew the lock, and then by the
49
+ * request layer on every write this client attempts, so work that has been
50
+ * running for minutes stops rather than writing over an edit that may have
51
+ * landed while the lock was gone.
52
+ */
53
+ export class DocumentLockLost extends Error {
54
+ constructor(message, documentId, cause) {
55
+ super(message);
56
+ this.name = "DocumentLockLost";
57
+ this.documentId = documentId;
58
+ this.cause = cause;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * How long a freshly taken lock lasts, from the server's own answer.
64
+ *
65
+ * `expiresAt` is the epoch-millisecond stamp the lock endpoints return.
66
+ * Comparing it against this machine's clock is the only way to learn a window
67
+ * an operator has retuned, and it is also the one place a clock skew can get
68
+ * in, so an answer outside a believable band falls back to the documented
69
+ * default rather than to a beat that never fires or fires constantly.
70
+ *
71
+ * @param {number|undefined} expiresAt
72
+ * @param {number} nowMs
73
+ * @param {number} [fallback]
74
+ * @returns {number}
75
+ */
76
+ export function lockTtlMs(expiresAt, nowMs, fallback = DOCUMENT_LOCK_TTL_MS) {
77
+ if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
78
+ const ttl = expiresAt - nowMs;
79
+ if (ttl > 0 && ttl <= MAX_BELIEVABLE_TTL_MS) return ttl;
80
+ }
81
+ return fallback;
82
+ }
83
+
84
+ /**
85
+ * Renews one document's lock until the block holding it exits.
86
+ *
87
+ * `refresh` is called with the document id and must reject on failure.
88
+ * `onLost` is called once, with the `DocumentLockLost`, when the lock can no
89
+ * longer be assumed.
90
+ *
91
+ * `clock` and `sleep` exist so the whole policy can be run on a fake clock:
92
+ * `sleep(delayMs)` resolves to true once the block has exited.
93
+ */
94
+ export class LockKeeper {
95
+ constructor(
96
+ refresh,
97
+ documentId,
98
+ ttlMs,
99
+ { onLost = null, clock = Date.now, sleep = null } = {},
100
+ ) {
101
+ this._refresh = refresh;
102
+ this._documentId = documentId;
103
+ // A window under two seconds leaves no room for a retry; treat it as a
104
+ // misconfiguration and beat at the documented rate instead.
105
+ this._ttlMs = ttlMs >= 2000 ? ttlMs : DOCUMENT_LOCK_TTL_MS;
106
+ this._onLost = onLost;
107
+ this._clock = clock;
108
+ this._sleep = sleep || ((ms) => this._defaultSleep(ms));
109
+ this._stopped = false;
110
+ this._wake = null;
111
+ this._running = null;
112
+ /** The `DocumentLockLost` this keeper raised, or null. */
113
+ this.lost = null;
114
+ }
115
+
116
+ /**
117
+ * Time between renewals: half the window, so a renewal that fails has a
118
+ * second chance before the lock it is renewing expires.
119
+ */
120
+ get intervalMs() {
121
+ return Math.max(1000, this._ttlMs / 2);
122
+ }
123
+
124
+ /**
125
+ * Time before retrying a renewal that failed. A blip should not end a run
126
+ * that the server still considers the holder of.
127
+ */
128
+ get retryMs() {
129
+ return Math.max(500, this._ttlMs / 10);
130
+ }
131
+
132
+ _defaultSleep(ms) {
133
+ if (this._stopped) return Promise.resolve(true);
134
+ return new Promise((resolve) => {
135
+ const timer = setTimeout(() => {
136
+ this._wake = null;
137
+ resolve(this._stopped);
138
+ }, ms);
139
+ // Never hold a Node process open on the beat alone.
140
+ if (timer && typeof timer.unref === "function") timer.unref();
141
+ this._wake = () => {
142
+ clearTimeout(timer);
143
+ this._wake = null;
144
+ resolve(true);
145
+ };
146
+ });
147
+ }
148
+
149
+ /** The beat. Resolves when the block exits or the lock is lost. */
150
+ async run() {
151
+ let deadline = this._clock() + this._ttlMs;
152
+ let delay = this.intervalMs;
153
+ while (!(await this._sleep(delay))) {
154
+ try {
155
+ await this._refresh(this._documentId);
156
+ } catch (error) {
157
+ // 423 is definitive: somebody else holds the document now, or ours
158
+ // expired or was dropped, and the server never takes it back.
159
+ // Anything else may be a blip, and is only fatal once the lock we are
160
+ // renewing has actually run out.
161
+ if (error?.status === 423 || this._clock() >= deadline) {
162
+ this._fail(error);
163
+ return;
164
+ }
165
+ delay = this.retryMs;
166
+ continue;
167
+ }
168
+ deadline = this._clock() + this._ttlMs;
169
+ delay = this.intervalMs;
170
+ }
171
+ }
172
+
173
+ start() {
174
+ this._running = this.run();
175
+ // Nothing awaits the beat: a failure inside it is reported through onLost,
176
+ // not through this promise.
177
+ this._running.catch(() => {});
178
+ return this._running;
179
+ }
180
+
181
+ stop() {
182
+ this._stopped = true;
183
+ if (this._wake) this._wake();
184
+ }
185
+
186
+ _fail(error) {
187
+ this.lost = new DocumentLockLost(
188
+ `The lock on document ${this._documentId} lapsed: it could not be renewed.`,
189
+ this._documentId,
190
+ error,
191
+ );
192
+ if (this._onLost) this._onLost(this.lost);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * What a `client.documents.locked(id, (lock) => ...)` block gets.
198
+ *
199
+ * The block does not have to consult it: a lost lock stops the next write on
200
+ * its own. Read `lock.lost` (or call `lock.raiseIfLost()`) to give up earlier,
201
+ * between steps of work that has not written anything yet.
202
+ */
203
+ export class DocumentLock {
204
+ constructor(documentId, keeper, lockId = null) {
205
+ this.documentId = documentId;
206
+ /** The holder id the acquire answered with. */
207
+ this.lockId = lockId;
208
+ this._keeper = keeper;
209
+ }
210
+
211
+ /** The `DocumentLockLost` if the lock lapsed, else null. */
212
+ get lost() {
213
+ return this._keeper ? this._keeper.lost : null;
214
+ }
215
+
216
+ raiseIfLost() {
217
+ if (this.lost) throw this.lost;
218
+ }
219
+ }
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
+
282
+ /**
283
+ * Hold `documentId`'s server-enforced lock for the length of `fn`.
284
+ * `client.documents.locked` is the entry point; see its doc comment.
285
+ *
286
+ * @param {object} client
287
+ * @param {string} documentId
288
+ * @param {(lock: DocumentLock) => any} fn
289
+ * @param {{ keepAlive?: boolean, acquireRetryMs?: number }} [options]
290
+ */
291
+ export async function withDocumentLock(
292
+ client,
293
+ documentId,
294
+ fn,
295
+ { keepAlive = true, acquireRetryMs = LOCK_ACQUIRE_RETRY_MS } = {},
296
+ ) {
297
+ const minted = mintLockId();
298
+ const info = await takeLock(client, documentId, minted, acquireRetryMs);
299
+
300
+ const lockId = info?.lockId ?? minted;
301
+ let keeper = null;
302
+ if (keepAlive) {
303
+ const ttlMs = lockTtlMs(info?.expiresAt, Date.now());
304
+ client.documentLockLost = null;
305
+ keeper = new LockKeeper(
306
+ (id) => client.documents.renewLock(id, lockId),
307
+ documentId,
308
+ ttlMs,
309
+ {
310
+ onLost: (lost) => {
311
+ client.documentLockLost = lost;
312
+ },
313
+ },
314
+ );
315
+ keeper.start();
316
+ }
317
+
318
+ let result;
319
+ let threw = false;
320
+ try {
321
+ result = await fn(new DocumentLock(documentId, keeper, lockId));
322
+ } catch (error) {
323
+ threw = true;
324
+ throw error;
325
+ } finally {
326
+ if (keeper) keeper.stop();
327
+ const lost = keeper ? keeper.lost : null;
328
+ client.documentLockLost = null;
329
+ // Best-effort release: the server TTL reclaims a stranded lock, and we must
330
+ // not let a release failure mask the real error from the body.
331
+ try {
332
+ await client.documents.releaseLock(documentId, lockId);
333
+ } catch {
334
+ /* the lock expires on its own */
335
+ }
336
+ // A block that ran to the end without the lock it asked for did not do what
337
+ // it says it did. Only throw when nothing else is already propagating, so
338
+ // the real failure is never masked.
339
+ if (lost && !threw) throw lost;
340
+ }
341
+ return result;
342
+ }