@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.21

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.21",
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,73 @@
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
+ /**
11
+ * The violations of a write refused by a layer constraint, camelCased
12
+ * ({constraint, namespace, layer, layerName, document, at, ids, value?,
13
+ * parts?}), or null for any other error. The answer lists at most 100, and
14
+ * `err.responseData["violation-count"]` is the total.
15
+ * @param {any} err
16
+ * @returns {Array<object>|null}
17
+ */
18
+ export function violationsOf(err) {
19
+ const vs = err?.status === 422 ? err?.responseData?.violations : undefined;
20
+ return Array.isArray(vs) ? transformResponse(vs) : null;
21
+ }
22
+
23
+ /** The request body of a constraint write: the list, and `expected` when given. */
24
+ export function constraintsBody(constraints, options) {
25
+ const body = constraints === undefined ? {} : { constraints };
26
+ if (options && Object.hasOwn(options, "expected")) {
27
+ body.expected = options.expected === undefined ? null : options.expected;
28
+ }
29
+ return body;
30
+ }
31
+
32
+ // What String.prototype.trim trims. The server trims a value-set's parts the
33
+ // same way, so a value read as listed here is read so there.
34
+ const splitParts = (value, delimiters) => {
35
+ const set = new Set([...(delimiters || "")]);
36
+ if (!set.size) return [value];
37
+ const out = [];
38
+ let part = "";
39
+ for (const ch of value) {
40
+ if (set.has(ch)) {
41
+ out.push(part);
42
+ part = "";
43
+ } else {
44
+ part += ch;
45
+ }
46
+ }
47
+ out.push(part);
48
+ return out;
49
+ };
50
+
51
+ /**
52
+ * Whether a value-set constraint ({values, delimiters?, parts?}) allows
53
+ * `value` as the server reads it: null and blank values pass, a non-string
54
+ * does not, and every part split on a delimiter (or only the first, with
55
+ * `parts: "first"`), trimmed, must be listed.
56
+ * @param {{values: string[], delimiters?: string, parts?: "all"|"first"}} constraint
57
+ * @param {any} value
58
+ * @returns {boolean}
59
+ */
60
+ export function valueSetAllows(constraint, value) {
61
+ if (value == null) return true;
62
+ if (typeof value !== "string") return false;
63
+ if (value.trim() === "") return true;
64
+ const delimiters = constraint?.delimiters || "";
65
+ const values = constraint?.values || [];
66
+ const parts = splitParts(value, delimiters);
67
+ if (constraint?.parts === "first") {
68
+ const firsts = new Set(values.map((v) => splitParts(v, delimiters)[0].trim()));
69
+ return firsts.has(parts[0].trim());
70
+ }
71
+ const allowed = new Set(values);
72
+ return parts.every((p) => p.trim() !== "" && allowed.has(p.trim()));
73
+ }
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,325 @@
1
+ import { isUnknownOutcome, retryUnknown } from "./http.js";
2
+
3
+ /**
4
+ * Keeping a document lock alive for as long as a `locked()` block runs.
5
+ *
6
+ * plaid-core expires a document lock about a minute after it was taken, and the
7
+ * holder's own writes are what renew it (`plaid.sql.operation` calls
8
+ * `refresh-locks!` on every write it accepts). That is the right rule for an
9
+ * editor, which writes as the person types. It is the wrong one for a service:
10
+ * a parser loads a model, reads the document, spends minutes in inference and
11
+ * only then writes, so for most of its run it holds nothing. The lock lapses in
12
+ * silence, a person's edit lands between the read the work was planned from and
13
+ * the write about to go out, and the write clobbers it.
14
+ *
15
+ * A `locked()` block therefore renews on a timer of its own. The renewal is an
16
+ * acquire that names the holder's `lockId`: the server refreshes the lock that
17
+ * id holds, and refuses one somebody else holds (the same user included).
18
+ *
19
+ * The beat is unconditional rather than "only when no write has gone out
20
+ * recently". The request layer knows the method of a call but not which
21
+ * document it touched, so a write to some other document would count as a
22
+ * renewal here and would not be one. Two extra requests a minute is the whole
23
+ * cost.
24
+ *
25
+ * A renewal that fails means the block is no longer holding what it asked for,
26
+ * so it stops the run: the loss is recorded on the client and every later write
27
+ * throws `DocumentLockLost`. The guard sits in the request layer because the
28
+ * callers are services, and a check they have to remember to call is a check
29
+ * that is missing somewhere.
30
+ */
31
+
32
+ /**
33
+ * The lock a `documents.locked()` block was holding is no longer held.
34
+ *
35
+ * Thrown by the keep-alive when it cannot renew the lock, and then by the
36
+ * request layer on every write this client attempts, so work that has been
37
+ * running for minutes stops rather than writing over an edit that may have
38
+ * landed while the lock was gone.
39
+ */
40
+ export class DocumentLockLost extends Error {
41
+ constructor(message, documentId, cause) {
42
+ super(message);
43
+ this.name = "DocumentLockLost";
44
+ this.documentId = documentId;
45
+ this.cause = cause;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * How long a freshly taken lock lasts, from the server's own answer.
51
+ *
52
+ * `expiresAt` is the epoch-millisecond moment the lock endpoints return, on
53
+ * the server's clock, so it is read against the server's clock too
54
+ * (`client.serverNow()`): this machine's clock being off cannot stretch or
55
+ * shrink the window. It is how a window an operator has retuned
56
+ * (`:plaid.server.locks/config :expiration-ms`) reaches the keeper.
57
+ *
58
+ * An answer that cannot be a window (none, already over, past an hour) is
59
+ * core's default, 60 s.
60
+ *
61
+ * @param {number} expiresAt
62
+ * @param {number} serverNowMs
63
+ * @returns {number}
64
+ */
65
+ export function lockTtlMs(expiresAt, serverNowMs) {
66
+ const ttl = expiresAt - serverNowMs;
67
+ // No answer at all, or one past an hour or already over: this machine's
68
+ // clock stood in for the server's (a page that cannot read its Date
69
+ // header) and is far off. Core's default window is the better guess.
70
+ return Number.isFinite(ttl) && ttl > 0 && ttl <= MAX_TTL_MS ? ttl : DEFAULT_TTL_MS;
71
+ }
72
+
73
+ // Core's default lock window, and the longest one believed.
74
+ const DEFAULT_TTL_MS = 60000;
75
+ const MAX_TTL_MS = 3600000;
76
+
77
+ /**
78
+ * Renews one document's lock until the block holding it exits.
79
+ *
80
+ * `refresh` is called with the document id and must reject on failure.
81
+ * `onLost` is called once, with the `DocumentLockLost`, when the lock can no
82
+ * longer be assumed.
83
+ *
84
+ * `clock` and `sleep` exist so the whole policy can be run on a fake clock:
85
+ * `sleep(delayMs)` resolves to true once the block has exited.
86
+ */
87
+ export class LockKeeper {
88
+ constructor(
89
+ refresh,
90
+ documentId,
91
+ ttlMs,
92
+ { onLost = null, clock = Date.now, sleep = null } = {},
93
+ ) {
94
+ this._refresh = refresh;
95
+ this._documentId = documentId;
96
+ this._ttlMs = ttlMs;
97
+ this._onLost = onLost;
98
+ this._clock = clock;
99
+ this._sleep = sleep || ((ms) => this._defaultSleep(ms));
100
+ this._stopped = false;
101
+ this._wake = null;
102
+ this._running = null;
103
+ /** The `DocumentLockLost` this keeper raised, or null. */
104
+ this.lost = null;
105
+ }
106
+
107
+ /**
108
+ * Time between renewals: half the window, so a renewal that fails has a
109
+ * second chance before the lock it is renewing expires.
110
+ */
111
+ get intervalMs() {
112
+ return Math.max(1000, this._ttlMs / 2);
113
+ }
114
+
115
+ /**
116
+ * Time before retrying a renewal that failed. A blip should not end a run
117
+ * that the server still considers the holder of.
118
+ */
119
+ get retryMs() {
120
+ return Math.max(500, this._ttlMs / 10);
121
+ }
122
+
123
+ _defaultSleep(ms) {
124
+ if (this._stopped) return Promise.resolve(true);
125
+ return new Promise((resolve) => {
126
+ const timer = setTimeout(() => {
127
+ this._wake = null;
128
+ resolve(this._stopped);
129
+ }, ms);
130
+ // Never hold a Node process open on the beat alone.
131
+ if (timer && typeof timer.unref === "function") timer.unref();
132
+ this._wake = () => {
133
+ clearTimeout(timer);
134
+ this._wake = null;
135
+ resolve(true);
136
+ };
137
+ });
138
+ }
139
+
140
+ /** The beat. Resolves when the block exits or the lock is lost. */
141
+ async run() {
142
+ let deadline = this._clock() + this._ttlMs;
143
+ let delay = this.intervalMs;
144
+ while (!(await this._sleep(delay))) {
145
+ try {
146
+ await this._refresh(this._documentId);
147
+ } catch (error) {
148
+ // 423 is definitive: somebody else holds the document now, or ours
149
+ // expired or was dropped, and the server never takes it back.
150
+ // Anything else may be a blip, and is only fatal once the lock we are
151
+ // renewing has actually run out.
152
+ if (error?.status === 423 || this._clock() >= deadline) {
153
+ this._fail(error);
154
+ return;
155
+ }
156
+ delay = this.retryMs;
157
+ continue;
158
+ }
159
+ deadline = this._clock() + this._ttlMs;
160
+ delay = this.intervalMs;
161
+ }
162
+ }
163
+
164
+ start() {
165
+ this._running = this.run();
166
+ // Nothing awaits the beat: a failure inside it is reported through onLost,
167
+ // not through this promise.
168
+ this._running.catch(() => {});
169
+ return this._running;
170
+ }
171
+
172
+ stop() {
173
+ this._stopped = true;
174
+ if (this._wake) this._wake();
175
+ }
176
+
177
+ _fail(error) {
178
+ this.lost = new DocumentLockLost(
179
+ `The lock on document ${this._documentId} lapsed: it could not be renewed.`,
180
+ this._documentId,
181
+ error,
182
+ );
183
+ if (this._onLost) this._onLost(this.lost);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * What a `client.documents.locked(id, (lock) => ...)` block gets.
189
+ *
190
+ * The block does not have to consult it: a lost lock stops the next write on
191
+ * its own. Read `lock.lost` (or call `lock.raiseIfLost()`) to give up earlier,
192
+ * between steps of work that has not written anything yet.
193
+ */
194
+ export class DocumentLock {
195
+ constructor(documentId, keeper, lockId = null) {
196
+ this.documentId = documentId;
197
+ /** The holder id the acquire answered with. */
198
+ this.lockId = lockId;
199
+ this._keeper = keeper;
200
+ }
201
+
202
+ /** The `DocumentLockLost` if the lock lapsed, else null. */
203
+ get lost() {
204
+ return this._keeper ? this._keeper.lost : null;
205
+ }
206
+
207
+ raiseIfLost() {
208
+ if (this.lost) throw this.lost;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * The pause before sending again an acquire whose outcome is unknown, which
214
+ * grows by this much each time (three sends in all).
215
+ */
216
+ export const LOCK_ACQUIRE_RETRY_MS = 500;
217
+
218
+ function mintLockId() {
219
+ return globalThis.crypto.randomUUID();
220
+ }
221
+
222
+ /**
223
+ * The acquire of a `locked()` block, under the holder id it minted.
224
+ *
225
+ * An acquire whose outcome is unknown (no answer, a timeout, a 502 or a 504)
226
+ * may have taken the lock. It is sent again under the same id as any write is
227
+ * (`retryUnknown`), which the server answers 200 while that holder has it.
228
+ * When every send is unknown (or the network went), the lock it may hold is
229
+ * released on the way out, so it does not stand in everyone's way until it
230
+ * expires.
231
+ */
232
+ async function takeLock(client, documentId, lockId, retryMs) {
233
+ try {
234
+ return await retryUnknown(
235
+ () => client.documents.acquireLock(documentId, undefined, lockId),
236
+ { delaysMs: [retryMs, 2 * retryMs] },
237
+ );
238
+ } catch (error) {
239
+ if (error?.status === 423) {
240
+ // The error body is raw JSON off the wire, so it is still kebab-cased.
241
+ const body = error.responseData || {};
242
+ const holder = body.userId || body["user-id"] || "another user";
243
+ const readable = new Error(
244
+ `This document is being edited by ${holder}. Try again once they're done.`,
245
+ );
246
+ readable.status = 423;
247
+ readable.statusText = error.statusText;
248
+ readable.url = error.url;
249
+ readable.method = error.method;
250
+ readable.responseData = error.responseData;
251
+ readable.cause = error;
252
+ throw readable;
253
+ }
254
+ if (isUnknownOutcome(error)) {
255
+ try {
256
+ await client.documents.releaseLock(documentId, lockId);
257
+ } catch {
258
+ /* the lock expires on its own */
259
+ }
260
+ }
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Hold `documentId`'s server-enforced lock for the length of `fn`.
267
+ * `client.documents.locked` is the entry point; see its doc comment.
268
+ *
269
+ * @param {object} client
270
+ * @param {string} documentId
271
+ * @param {(lock: DocumentLock) => any} fn
272
+ * @param {{ keepAlive?: boolean, acquireRetryMs?: number }} [options]
273
+ */
274
+ export async function withDocumentLock(
275
+ client,
276
+ documentId,
277
+ fn,
278
+ { keepAlive = true, acquireRetryMs = LOCK_ACQUIRE_RETRY_MS } = {},
279
+ ) {
280
+ const minted = mintLockId();
281
+ const info = await takeLock(client, documentId, minted, acquireRetryMs);
282
+
283
+ const lockId = info?.lockId ?? minted;
284
+ let keeper = null;
285
+ if (keepAlive) {
286
+ const ttlMs = lockTtlMs(info.expiresAt, client.serverNow().getTime());
287
+ client.documentLockLost = null;
288
+ keeper = new LockKeeper(
289
+ (id) => client.documents.renewLock(id, lockId),
290
+ documentId,
291
+ ttlMs,
292
+ {
293
+ onLost: (lost) => {
294
+ client.documentLockLost = lost;
295
+ },
296
+ },
297
+ );
298
+ keeper.start();
299
+ }
300
+
301
+ let result;
302
+ let threw = false;
303
+ try {
304
+ result = await fn(new DocumentLock(documentId, keeper, lockId));
305
+ } catch (error) {
306
+ threw = true;
307
+ throw error;
308
+ } finally {
309
+ if (keeper) keeper.stop();
310
+ const lost = keeper ? keeper.lost : null;
311
+ client.documentLockLost = null;
312
+ // Best-effort release: the server TTL reclaims a stranded lock, and we must
313
+ // not let a release failure mask the real error from the body.
314
+ try {
315
+ await client.documents.releaseLock(documentId, lockId);
316
+ } catch {
317
+ /* the lock expires on its own */
318
+ }
319
+ // A block that ran to the end without the lock it asked for did not do what
320
+ // it says it did. Only throw when nothing else is already propagating, so
321
+ // the real failure is never masked.
322
+ if (lost && !threw) throw lost;
323
+ }
324
+ return result;
325
+ }