@larc-iu/plaid-client 0.2.0-alpha.16 → 0.2.0-alpha.17
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 +285 -60
- package/package.json +1 -1
- package/src/created.js +29 -0
- package/src/http.js +153 -56
- package/src/index.js +323 -195
- package/src/metadataOps.js +117 -0
- package/src/provenance.js +2 -16
- package/src/services.js +42 -5
- package/src/sse.js +29 -3
- package/src/transforms.js +7 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata ops: the body of every metadata PATCH, and the `metadata` of a
|
|
3
|
+
* bulk update entry. An op is `{op: 'set', path, value}` or
|
|
4
|
+
* `{op: 'delete', path}`, where `path` is a non-empty array of keys into the
|
|
5
|
+
* nested metadata, the first a top-level key. See the manual, "Metadata".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PLAID_NAMESPACE } from './roles.js';
|
|
9
|
+
import { PROVENANCE_KEYS } from './provenance.js';
|
|
10
|
+
|
|
11
|
+
const RESERVED_KEYS = new Set([PLAID_NAMESPACE, ...PROVENANCE_KEYS]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True for a top-level metadata key Plaid keeps for itself: the shared `plaid`
|
|
15
|
+
* namespace (settings every app reads, such as the text direction) and the
|
|
16
|
+
* provenance keys. Neither is ever a user's metadata field, so an app neither
|
|
17
|
+
* lists it as one nor writes a field's value to it.
|
|
18
|
+
* @param {string} key
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export const isReservedMetadataKey = (key) => RESERVED_KEYS.has(key);
|
|
22
|
+
|
|
23
|
+
// The longest top-level key the server takes, in UTF-16 code units (Java's
|
|
24
|
+
// String length, which is what it counts).
|
|
25
|
+
const MAX_KEY_LENGTH = 200;
|
|
26
|
+
|
|
27
|
+
// Java's Character.isWhitespace, which the server's blank check uses: the
|
|
28
|
+
// Unicode space separators except the three no-break spaces, and the ASCII
|
|
29
|
+
// whitespace controls.
|
|
30
|
+
const isJavaWhitespace = (c) =>
|
|
31
|
+
(/[\p{Zs}\p{Zl}\p{Zp}]/u.test(c) && c !== '\u00A0' && c !== '\u2007' && c !== '\u202F') ||
|
|
32
|
+
/[\t\n\u000B\f\r\u001C-\u001F]/.test(c);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when the server accepts `k` as a top-level metadata key: not blank,
|
|
36
|
+
* at most 200 UTF-16 code units, and no ASCII control character.
|
|
37
|
+
* @param {string} k
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
const validMetadataKey = (k) =>
|
|
41
|
+
typeof k === 'string' &&
|
|
42
|
+
k.length <= MAX_KEY_LENGTH &&
|
|
43
|
+
!/[\u0000-\u001F\u007F]/.test(k) &&
|
|
44
|
+
![...k].every(isJavaWhitespace);
|
|
45
|
+
|
|
46
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The ops that set each top-level key of `fragment`, deleting a key whose
|
|
50
|
+
* value is null. This is how a provenance fragment (verifyOnEdit,
|
|
51
|
+
* contributeOnEdit, ...) is sent as a patch.
|
|
52
|
+
* @param {Object|null|undefined} fragment
|
|
53
|
+
* @returns {Array<{op: string, path: string[], value?: any}>}
|
|
54
|
+
*/
|
|
55
|
+
export const metadataOps = (fragment) =>
|
|
56
|
+
Object.entries(fragment || {}).map(([k, v]) =>
|
|
57
|
+
v === null ? { op: 'delete', path: [k] } : { op: 'set', path: [k], value: v },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const applyOp = (node, path, depth, o) => {
|
|
61
|
+
const [k, ...more] = path.slice(depth);
|
|
62
|
+
if (more.length === 0) {
|
|
63
|
+
const out = { ...node };
|
|
64
|
+
if (o.op === 'set') out[k] = o.value;
|
|
65
|
+
else delete out[k];
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const child = Object.prototype.hasOwnProperty.call(node, k) ? node[k] : undefined;
|
|
69
|
+
if (child === undefined) {
|
|
70
|
+
return o.op === 'set' ? { ...node, [k]: applyOp({}, path, depth + 1, o) } : node;
|
|
71
|
+
}
|
|
72
|
+
if (!isObject(child)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Metadata path ${JSON.stringify(path.slice(0, depth + 1))} holds a value that is not an object`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { ...node, [k]: applyOp(child, path, depth + 1, o) };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply ops to a local copy of an entity's metadata the way the server does,
|
|
82
|
+
* for an optimistic update. Returns a new object and never mutates its input.
|
|
83
|
+
* Throws where the server would refuse (a path through a non-object, or a
|
|
84
|
+
* first key that is blank, over 200 characters or holds a control character).
|
|
85
|
+
* @param {Object|null|undefined} metadata
|
|
86
|
+
* @param {Array<{op: string, path: string[], value?: any}>} ops
|
|
87
|
+
* @returns {Object}
|
|
88
|
+
*/
|
|
89
|
+
export const applyMetadataOps = (metadata, ops) =>
|
|
90
|
+
(ops || []).reduce((m, o) => {
|
|
91
|
+
if (!Array.isArray(o.path) || o.path.length === 0) {
|
|
92
|
+
throw new Error('A metadata op needs a non-empty path');
|
|
93
|
+
}
|
|
94
|
+
if (!validMetadataKey(o.path[0])) {
|
|
95
|
+
throw new Error('Invalid metadata key');
|
|
96
|
+
}
|
|
97
|
+
if (o.op !== 'set' && o.op !== 'delete') {
|
|
98
|
+
throw new Error(`Unknown metadata op '${o.op}': expected set or delete`);
|
|
99
|
+
}
|
|
100
|
+
if (o.op === 'set' && !('value' in o)) {
|
|
101
|
+
throw new Error('A set op needs a value');
|
|
102
|
+
}
|
|
103
|
+
return applyOp(m, o.path, 0, o);
|
|
104
|
+
}, { ...(metadata || {}) });
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Merge a top-level fragment into a local copy, a null value deleting the
|
|
108
|
+
* key: applyMetadataOps over metadataOps(fragment), so the same result as
|
|
109
|
+
* sending those ops, refused where the server would refuse them. How an app
|
|
110
|
+
* mirrors a provenance stamp (verifyOnEdit, contributeOnEdit, ...) on the row
|
|
111
|
+
* it shows. Returns a new object.
|
|
112
|
+
* @param {Object|null|undefined} metadata
|
|
113
|
+
* @param {Object|null|undefined} fragment
|
|
114
|
+
* @returns {Object}
|
|
115
|
+
*/
|
|
116
|
+
export const mergeMetadata = (metadata, fragment) =>
|
|
117
|
+
applyMetadataOps(metadata, metadataOps(fragment));
|
package/src/provenance.js
CHANGED
|
@@ -228,8 +228,8 @@ export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRME
|
|
|
228
228
|
/**
|
|
229
229
|
* The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
|
|
230
230
|
* (write-contract rule 3): the contributed stamp, plus provConfirmed: null
|
|
231
|
-
* so an earlier confirmation is dropped (
|
|
232
|
-
*
|
|
231
|
+
* so an earlier confirmation is dropped (send it with metadataOps, which
|
|
232
|
+
* turns a null into a delete, or apply it locally with mergeMetadata). Prediction extras a machine
|
|
233
233
|
* recorded stay, as history.
|
|
234
234
|
* @param {Object|null|undefined} metadata - unused today; the fragment is
|
|
235
235
|
* the same whatever the entity was, and the parameter keeps the shape of
|
|
@@ -242,20 +242,6 @@ export const contributeOnEdit = (metadata, userId) => ({
|
|
|
242
242
|
[PROV.confirmedKey]: null,
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
/**
|
|
246
|
-
* Merge a metadata fragment the way the server's PATCH does — a null value
|
|
247
|
-
* deletes the key — for callers that keep a local copy or send a full
|
|
248
|
-
* replacement (setMetadata). Returns a new object.
|
|
249
|
-
*/
|
|
250
|
-
export const mergeMetadata = (metadata, fragment) => {
|
|
251
|
-
const out = { ...(metadata || {}) };
|
|
252
|
-
for (const [k, v] of Object.entries(fragment || {})) {
|
|
253
|
-
if (v === null) delete out[k];
|
|
254
|
-
else out[k] = v;
|
|
255
|
-
}
|
|
256
|
-
return out;
|
|
257
|
-
};
|
|
258
|
-
|
|
259
245
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
260
246
|
export const serviceSource = (serviceId) => `service:${serviceId}`;
|
|
261
247
|
|
package/src/services.js
CHANGED
|
@@ -138,6 +138,13 @@ export function reportRequestEvent(client, projectId, requestId, body) {
|
|
|
138
138
|
);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* HTTP statuses a refused service channel answers with that retrying cannot
|
|
143
|
+
* fix: a bad or revoked token, no write access to the project, a project that
|
|
144
|
+
* does not exist. The same set as the Python client's.
|
|
145
|
+
*/
|
|
146
|
+
const PERMANENT_STATUSES = new Set([400, 401, 403, 404, 405, 422]);
|
|
147
|
+
|
|
141
148
|
/**
|
|
142
149
|
* Register a service and handle incoming work requests.
|
|
143
150
|
*
|
|
@@ -152,7 +159,9 @@ export function reportRequestEvent(client, projectId, requestId, body) {
|
|
|
152
159
|
* @param {function} onServiceRequest - Handler callback (data, responseHelper)
|
|
153
160
|
* @param {Object} extras - Optional additional metadata
|
|
154
161
|
* @param {function} [onStatus] - Optional callback (event, projectId, detail) for
|
|
155
|
-
* connection-state transitions: 'registered', 'reconnected', 'disconnected'
|
|
162
|
+
* connection-state transitions: 'registered', 'reconnected', 'disconnected',
|
|
163
|
+
* and 'stopped' when the server refuses the channel for a reason retrying
|
|
164
|
+
* cannot fix (see PERMANENT_STATUSES), which ends the registration.
|
|
156
165
|
* Called once per transition, not once per retry.
|
|
157
166
|
* @returns {Object} ServiceRegistration with .stop(), .isRunning(), .isConnected(),
|
|
158
167
|
* .serviceInfo
|
|
@@ -237,11 +246,20 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
237
246
|
const finished = () => cancelled.delete(requestId);
|
|
238
247
|
|
|
239
248
|
// Every service is told who asked (`requesterId`), beside the payload
|
|
240
|
-
// when that is a plain object, and on the helper regardless.
|
|
249
|
+
// when that is a plain object, and on the helper regardless. A delegating
|
|
250
|
+
// service (extras `delegation: true`) also gets a short-lived token for the
|
|
251
|
+
// requesting user (`delegatedToken`) and the projects that token reaches
|
|
252
|
+
// (`delegatedProjects`, the request's own first), as the Python client
|
|
253
|
+
// hands them over.
|
|
241
254
|
const requesterId = payload.requesterId || null;
|
|
255
|
+
const extra = {
|
|
256
|
+
...(requesterId ? { requesterId } : {}),
|
|
257
|
+
...(payload.delegatedToken ? { delegatedToken: payload.delegatedToken } : {}),
|
|
258
|
+
...(payload.delegatedProjects ? { delegatedProjects: payload.delegatedProjects } : {}),
|
|
259
|
+
};
|
|
242
260
|
const data =
|
|
243
|
-
|
|
244
|
-
? { ...payload.data,
|
|
261
|
+
Object.keys(extra).length && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
|
262
|
+
? { ...payload.data, ...extra }
|
|
245
263
|
: payload.data;
|
|
246
264
|
|
|
247
265
|
const scope = createCancelScope(() => cancelled.get(requestId) === true);
|
|
@@ -328,10 +346,23 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
328
346
|
// means a service restart. A reopened channel counts as connected only once
|
|
329
347
|
// the server has actually answered it (readyState OPEN), not merely because
|
|
330
348
|
// the attempt was made.
|
|
349
|
+
//
|
|
350
|
+
// A channel the server REFUSED for a reason retrying cannot fix (the token
|
|
351
|
+
// was revoked, the user lost write access, the project is gone) ends the
|
|
352
|
+
// registration instead: the server closes a channel whose opener lost the
|
|
353
|
+
// right to it, and reopening it every few seconds forever would only hide
|
|
354
|
+
// that from the operator.
|
|
331
355
|
reconnectTimer = setInterval(() => {
|
|
332
356
|
if (!isRunning || !connection) return;
|
|
333
357
|
if (connection.readyState === 1) { noteConnected(); return; }
|
|
334
358
|
if (connection.readyState === 2) { // CLOSED (dropped or failed)
|
|
359
|
+
const status = connection.error?.status;
|
|
360
|
+
if (PERMANENT_STATUSES.has(status)) {
|
|
361
|
+
noteDisconnected();
|
|
362
|
+
serviceRegistration.stop();
|
|
363
|
+
report('stopped', `HTTP ${status}`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
335
366
|
noteDisconnected();
|
|
336
367
|
try { connection = openChannel(); } catch (_) { /* retry next tick */ }
|
|
337
368
|
}
|
|
@@ -368,6 +399,9 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
368
399
|
* @param {Object} [opts]
|
|
369
400
|
* @param {string} [opts.requestId] - A client-minted request id (UUID)
|
|
370
401
|
* @param {function} [opts.onAccepted] - Called with the request id once the server has it
|
|
402
|
+
* @param {string[]} [opts.projectIds] - Other projects the request is about, beside
|
|
403
|
+
* `projectId`. A delegating service's token is scoped to `projectId` and to those of
|
|
404
|
+
* these the requester can read, and reaches nothing else
|
|
371
405
|
* @returns {Promise<any>} The service's result
|
|
372
406
|
*/
|
|
373
407
|
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
|
|
@@ -381,7 +415,10 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
381
415
|
group && data && typeof data === 'object' && !Array.isArray(data)
|
|
382
416
|
? { ...data, operationGroup: { id: group.id, message: group.message } }
|
|
383
417
|
: data;
|
|
384
|
-
const
|
|
418
|
+
const query = new URLSearchParams();
|
|
419
|
+
if (opts.requestId) query.set('request-id', opts.requestId);
|
|
420
|
+
if (opts.projectIds?.length) query.set('project-ids', opts.projectIds.join(','));
|
|
421
|
+
const qs = String(query) ? `?${query}` : '';
|
|
385
422
|
return streamServiceRequest(
|
|
386
423
|
client,
|
|
387
424
|
{
|
package/src/sse.js
CHANGED
|
@@ -12,6 +12,11 @@ import { transformResponse } from './transforms.js';
|
|
|
12
12
|
// cadences, so a healthy idle stream never trips it.
|
|
13
13
|
export const SSE_IDLE_TIMEOUT_MS = 60000;
|
|
14
14
|
|
|
15
|
+
// The last failure warned about per stream URL, so a stream retried every few
|
|
16
|
+
// seconds through an outage warns once per distinct failure rather than once
|
|
17
|
+
// per attempt. Cleared when the stream opens. Mirrors the Python client.
|
|
18
|
+
const lastWarning = new Map();
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* Project a parsed SSE `data:` object onto what a listener callback sees.
|
|
17
22
|
*
|
|
@@ -42,7 +47,10 @@ export function eventPayload(eventType, parsed) {
|
|
|
42
47
|
* /listen bus; service request channels pass their own. Only /listen emits
|
|
43
48
|
* `heartbeat` events needing a POST confirmation — other streams keep
|
|
44
49
|
* themselves alive with ignored SSE comments.
|
|
45
|
-
* @returns {Object} SSE connection with .close(), .getStats(), .readyState
|
|
50
|
+
* @returns {Object} SSE connection with .close(), .getStats(), .readyState,
|
|
51
|
+
* and .error: once CLOSED, why (null when this side closed it). A refused
|
|
52
|
+
* stream's error carries the HTTP `status`, and a stream the server ended
|
|
53
|
+
* carries an error named `StreamClosed`.
|
|
46
54
|
*/
|
|
47
55
|
export function createSSEConnection(client, projectId, onEvent, path) {
|
|
48
56
|
const streamPath = path || `/api/v1/projects/${projectId}/listen`;
|
|
@@ -88,6 +96,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
88
96
|
|
|
89
97
|
const sseConnection = {
|
|
90
98
|
readyState: 0, // CONNECTING
|
|
99
|
+
error: null,
|
|
91
100
|
close: () => {
|
|
92
101
|
if (!isClosed) {
|
|
93
102
|
isClosed = true;
|
|
@@ -130,11 +139,14 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
130
139
|
// takes over.
|
|
131
140
|
console.warn('Service registration rejected (409): another instance of this service is already connected; will retry');
|
|
132
141
|
}
|
|
133
|
-
|
|
142
|
+
const refused = new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
143
|
+
refused.status = response.status;
|
|
144
|
+
throw refused;
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
isConnected = true;
|
|
137
148
|
sseConnection.readyState = 1; // OPEN
|
|
149
|
+
lastWarning.delete(url);
|
|
138
150
|
|
|
139
151
|
const reader = response.body.getReader();
|
|
140
152
|
const decoder = new TextDecoder();
|
|
@@ -188,9 +200,23 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
188
200
|
}
|
|
189
201
|
}
|
|
190
202
|
}
|
|
203
|
+
if (!isClosed) {
|
|
204
|
+
// The server ended the stream, for instance because the opener lost
|
|
205
|
+
// the right to it. Say so, so a caller can tell it from a close of
|
|
206
|
+
// its own.
|
|
207
|
+
sseConnection.error = Object.assign(new Error('The server closed the stream'), {
|
|
208
|
+
name: 'StreamClosed',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
191
211
|
} catch (error) {
|
|
212
|
+
if (!isClosed) sseConnection.error = error;
|
|
192
213
|
if (error.name !== 'AbortError') {
|
|
193
|
-
|
|
214
|
+
const url = `${client.baseUrl}${streamPath}`;
|
|
215
|
+
const message = String(error?.message || error);
|
|
216
|
+
if (lastWarning.get(url) !== message) {
|
|
217
|
+
lastWarning.set(url, message);
|
|
218
|
+
console.warn('SSE connection error:', error);
|
|
219
|
+
}
|
|
194
220
|
}
|
|
195
221
|
} finally {
|
|
196
222
|
clearIdleTimer();
|
package/src/transforms.js
CHANGED
|
@@ -27,6 +27,12 @@ export function transformKeyFromCamel(key) {
|
|
|
27
27
|
// usual case conversion.
|
|
28
28
|
const OPAQUE_KEYS = new Set(['metadata', 'config', 'bindings']);
|
|
29
29
|
|
|
30
|
+
// A request's `metadata` may also be a list of metadata ops (a bulk update
|
|
31
|
+
// entry), whose values are user data too, so it passes through verbatim.
|
|
32
|
+
const isOpaque = (key, value) =>
|
|
33
|
+
typeof value === 'object' && value !== null &&
|
|
34
|
+
(Array.isArray(value) ? key === 'metadata' : OPAQUE_KEYS.has(key));
|
|
35
|
+
|
|
30
36
|
/**
|
|
31
37
|
* Recursively transform request object keys from camelCase to kebab-case.
|
|
32
38
|
* Preserves `metadata` and `config` contents without transformation.
|
|
@@ -39,7 +45,7 @@ export function transformRequest(obj) {
|
|
|
39
45
|
const transformed = {};
|
|
40
46
|
for (const [key, value] of Object.entries(obj)) {
|
|
41
47
|
const newKey = transformKeyFromCamel(key);
|
|
42
|
-
if (
|
|
48
|
+
if (isOpaque(key, value)) {
|
|
43
49
|
transformed[newKey] = value;
|
|
44
50
|
} else {
|
|
45
51
|
transformed[newKey] = transformRequest(value);
|