@larc-iu/plaid-client 0.2.0-alpha.14 → 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 +373 -79
- package/package.json +1 -1
- package/src/created.js +29 -0
- package/src/http.js +230 -116
- package/src/index.js +551 -371
- package/src/metadataOps.js +117 -0
- package/src/pagination.js +3 -7
- package/src/provenance.js +2 -16
- package/src/serviceSchema.js +27 -1
- package/src/services.js +56 -27
- 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/pagination.js
CHANGED
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
* headers, base URL, and response transforms all apply exactly as for any
|
|
12
12
|
* other request.
|
|
13
13
|
*
|
|
14
|
-
* A page is a read, so
|
|
15
|
-
* (see the
|
|
16
|
-
*
|
|
17
|
-
* pool, in order, against the state the batch has not committed yet.
|
|
14
|
+
* A page is a read, so it goes over the wire even when the helper is handed a
|
|
15
|
+
* batch (see the note at the top of http.js): each page is answered from the
|
|
16
|
+
* pool, in order, against the state no batch has committed yet.
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
19
|
// Merge caller query params with paging params, dropping undefined/null so we
|
|
@@ -57,7 +56,6 @@ export async function listAll(client, path, { pageSize = 1000, query = {} } = {}
|
|
|
57
56
|
do {
|
|
58
57
|
const response = await client._request('GET', path, {
|
|
59
58
|
queryParams: buildQueryParams(query, pageSize, cursor),
|
|
60
|
-
bypassBatch: true,
|
|
61
59
|
});
|
|
62
60
|
all.push(...entriesOf(response, path));
|
|
63
61
|
prevCursor = cursor;
|
|
@@ -87,7 +85,6 @@ export async function listAll(client, path, { pageSize = 1000, query = {} } = {}
|
|
|
87
85
|
export async function listPage(client, path, { limit, cursor, query = {} } = {}) {
|
|
88
86
|
return client._request('GET', path, {
|
|
89
87
|
queryParams: buildQueryParams(query, limit, cursor),
|
|
90
|
-
bypassBatch: true,
|
|
91
88
|
});
|
|
92
89
|
}
|
|
93
90
|
|
|
@@ -108,7 +105,6 @@ export async function* iterPages(client, path, { pageSize = 1000, query = {} } =
|
|
|
108
105
|
do {
|
|
109
106
|
const response = await client._request('GET', path, {
|
|
110
107
|
queryParams: buildQueryParams(query, pageSize, cursor),
|
|
111
|
-
bypassBatch: true,
|
|
112
108
|
});
|
|
113
109
|
const entries = entriesOf(response, path);
|
|
114
110
|
// Suppress the trailing empty page that the server emits when a collection's
|
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/serviceSchema.js
CHANGED
|
@@ -22,10 +22,15 @@
|
|
|
22
22
|
* { key, label, type, description?, default?, required?,
|
|
23
23
|
* options?: [{value, label}], // enum / multiselect
|
|
24
24
|
* min?, max?, step?, slider?, // number (slider: drag, don't type)
|
|
25
|
-
* placeholder?, multiline
|
|
25
|
+
* placeholder?, multiline?, // string
|
|
26
|
+
* scope? } // field: 'Sentence' | 'Word' | 'Morpheme'
|
|
26
27
|
* ]
|
|
27
28
|
* }
|
|
28
29
|
*
|
|
30
|
+
* A `field` parameter names one of the project's annotation fields at its
|
|
31
|
+
* `scope`. A UI that knows the project offers those fields to choose from; to
|
|
32
|
+
* everything here, and to a UI that does not, it is a string.
|
|
33
|
+
*
|
|
29
34
|
* A parameter's `key` is a string VALUE, so it passes over the wire verbatim;
|
|
30
35
|
* the UI sends `{ [param.key]: value }` in the request data. Declare each `key`
|
|
31
36
|
* in your service's own convention (Python: snake_case; JS: camelCase) and read
|
|
@@ -50,6 +55,20 @@ export const TASKS = Object.freeze({
|
|
|
50
55
|
LINK_VOCAB: "link-vocab",
|
|
51
56
|
/** Propose an interlinear analysis (morpheme segmentation + glosses) for words. */
|
|
52
57
|
ANALYZE: "analyze",
|
|
58
|
+
/**
|
|
59
|
+
* Draft a meaning representation graph over the sentences of a document,
|
|
60
|
+
* for correction by hand. The request names a `scope` ('document' or
|
|
61
|
+
* 'sentence'), a `sentence` number when the scope is one sentence, and
|
|
62
|
+
* `overwrite`. See plaid-umr's drafting services.
|
|
63
|
+
*
|
|
64
|
+
* Apart from `analyze` on purpose. A task is the CONTRACT a spot offers a
|
|
65
|
+
* service against, and these two share almost none of it: an analyze
|
|
66
|
+
* request names the three token layers plus the language, metalanguage and
|
|
67
|
+
* orthography, and writes morphemes and glosses. Both claimed `analyze` for
|
|
68
|
+
* a while, so every app offering an analyze spot offered the other app's
|
|
69
|
+
* services, which could not answer the request they would have been given.
|
|
70
|
+
*/
|
|
71
|
+
DRAFT_GRAPH: "draft-graph",
|
|
53
72
|
/** Propose a free translation for each sentence. */
|
|
54
73
|
TRANSLATE: "translate",
|
|
55
74
|
/**
|
|
@@ -64,6 +83,13 @@ export const TASKS = Object.freeze({
|
|
|
64
83
|
DETECT_SPEECH: "detect-speech",
|
|
65
84
|
/** A conversational assistant over a project (chat turns; see plaid-agent). */
|
|
66
85
|
ASSIST: "assist",
|
|
86
|
+
/**
|
|
87
|
+
* Score one document's annotation against another document's of the same
|
|
88
|
+
* text (two annotators' copies), writing a report on the scored document's
|
|
89
|
+
* metadata and never an annotation. The request names the other document
|
|
90
|
+
* as `against`. See plaid-umr's AnCast service.
|
|
91
|
+
*/
|
|
92
|
+
COMPARE: "compare",
|
|
67
93
|
});
|
|
68
94
|
|
|
69
95
|
/**
|
package/src/services.js
CHANGED
|
@@ -92,14 +92,7 @@ export function serviceErrorMessage(error, serviceName = '', secrets = []) {
|
|
|
92
92
|
* @returns {Promise<Array>} [{serviceId, serviceName, description, extras, online, lastSeenAt}]
|
|
93
93
|
*/
|
|
94
94
|
export function discoverServices(client, projectId) {
|
|
95
|
-
|
|
96
|
-
// happens to be open on this shared client. The assistant's availability
|
|
97
|
-
// probe polls for 49 seconds after a mount and landed inside a Grew rewrite
|
|
98
|
-
// or an import, where the answer was `{batched: true}`, cached as the
|
|
99
|
-
// service list, and every later render threw on it until a reload.
|
|
100
|
-
return client._request('GET', `/api/v1/projects/${projectId}/services`, {
|
|
101
|
-
bypassBatch: true,
|
|
102
|
-
});
|
|
95
|
+
return client._request('GET', `/api/v1/projects/${projectId}/services`);
|
|
103
96
|
}
|
|
104
97
|
|
|
105
98
|
/**
|
|
@@ -113,17 +106,21 @@ export function discoverServices(client, projectId) {
|
|
|
113
106
|
* @returns {Promise<void>}
|
|
114
107
|
*/
|
|
115
108
|
export function discardService(client, projectId, serviceId) {
|
|
116
|
-
|
|
109
|
+
// outOfBand: the registry is not project data (see the note at the top of
|
|
110
|
+
// http.js).
|
|
111
|
+
return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`, {
|
|
112
|
+
outOfBand: true,
|
|
113
|
+
});
|
|
117
114
|
}
|
|
118
115
|
|
|
119
116
|
/**
|
|
120
117
|
* POST a progress, result or error event for an in-flight request; the server
|
|
121
118
|
* relays it to the waiting requester. The Python twin is `_report_event`.
|
|
122
119
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
120
|
+
* outOfBand: these are signals to whoever is waiting, not writes to the
|
|
121
|
+
* project (see the note at the top of http.js). A service may report them on
|
|
122
|
+
* the batch it is filling, where queueing them would hold every one back until
|
|
123
|
+
* submit, deliver none at all if the batch aborts, and take slots in the
|
|
127
124
|
* batch's results. The requester would hear nothing and wait out its idle
|
|
128
125
|
* timeout on work that had already finished.
|
|
129
126
|
*
|
|
@@ -137,10 +134,17 @@ export function reportRequestEvent(client, projectId, requestId, body) {
|
|
|
137
134
|
return client._request(
|
|
138
135
|
'POST',
|
|
139
136
|
`/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}/events`,
|
|
140
|
-
{ body,
|
|
137
|
+
{ body, outOfBand: true },
|
|
141
138
|
);
|
|
142
139
|
}
|
|
143
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
|
+
|
|
144
148
|
/**
|
|
145
149
|
* Register a service and handle incoming work requests.
|
|
146
150
|
*
|
|
@@ -155,7 +159,9 @@ export function reportRequestEvent(client, projectId, requestId, body) {
|
|
|
155
159
|
* @param {function} onServiceRequest - Handler callback (data, responseHelper)
|
|
156
160
|
* @param {Object} extras - Optional additional metadata
|
|
157
161
|
* @param {function} [onStatus] - Optional callback (event, projectId, detail) for
|
|
158
|
-
* 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.
|
|
159
165
|
* Called once per transition, not once per retry.
|
|
160
166
|
* @returns {Object} ServiceRegistration with .stop(), .isRunning(), .isConnected(),
|
|
161
167
|
* .serviceInfo
|
|
@@ -240,11 +246,20 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
240
246
|
const finished = () => cancelled.delete(requestId);
|
|
241
247
|
|
|
242
248
|
// Every service is told who asked (`requesterId`), beside the payload
|
|
243
|
-
// 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.
|
|
244
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
|
+
};
|
|
245
260
|
const data =
|
|
246
|
-
|
|
247
|
-
? { ...payload.data,
|
|
261
|
+
Object.keys(extra).length && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
|
262
|
+
? { ...payload.data, ...extra }
|
|
248
263
|
: payload.data;
|
|
249
264
|
|
|
250
265
|
const scope = createCancelScope(() => cancelled.get(requestId) === true);
|
|
@@ -331,10 +346,23 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
331
346
|
// means a service restart. A reopened channel counts as connected only once
|
|
332
347
|
// the server has actually answered it (readyState OPEN), not merely because
|
|
333
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.
|
|
334
355
|
reconnectTimer = setInterval(() => {
|
|
335
356
|
if (!isRunning || !connection) return;
|
|
336
357
|
if (connection.readyState === 1) { noteConnected(); return; }
|
|
337
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
|
+
}
|
|
338
366
|
noteDisconnected();
|
|
339
367
|
try { connection = openChannel(); } catch (_) { /* retry next tick */ }
|
|
340
368
|
}
|
|
@@ -371,6 +399,9 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
371
399
|
* @param {Object} [opts]
|
|
372
400
|
* @param {string} [opts.requestId] - A client-minted request id (UUID)
|
|
373
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
|
|
374
405
|
* @returns {Promise<any>} The service's result
|
|
375
406
|
*/
|
|
376
407
|
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
|
|
@@ -384,7 +415,10 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
384
415
|
group && data && typeof data === 'object' && !Array.isArray(data)
|
|
385
416
|
? { ...data, operationGroup: { id: group.id, message: group.message } }
|
|
386
417
|
: data;
|
|
387
|
-
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}` : '';
|
|
388
422
|
return streamServiceRequest(
|
|
389
423
|
client,
|
|
390
424
|
{
|
|
@@ -487,13 +521,8 @@ export function createCancelScope(isCancelled) {
|
|
|
487
521
|
* still ends with whatever the service then reports, on the stream of whoever
|
|
488
522
|
* is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
|
|
489
523
|
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* data. The Stop button lives in the app's chrome, on the same client an
|
|
493
|
-
* import or a bulk edit is batching on, and a stop that waits for that batch to
|
|
494
|
-
* submit has stopped nothing (and if the batch aborts, it never arrives at
|
|
495
|
-
* all), while the queued DELETE shifts every result index the batch's caller
|
|
496
|
-
* reads back.
|
|
524
|
+
* outOfBand: a DELETE, but not a write. It signals a running service and
|
|
525
|
+
* changes no project data (see the note at the top of http.js).
|
|
497
526
|
*
|
|
498
527
|
* @param {Object} client - PlaidClient instance
|
|
499
528
|
* @param {string} projectId - Project UUID
|
|
@@ -504,7 +533,7 @@ export function cancelServiceRequest(client, projectId, requestId) {
|
|
|
504
533
|
return client._request(
|
|
505
534
|
'DELETE',
|
|
506
535
|
`/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
|
|
507
|
-
{
|
|
536
|
+
{ outOfBand: true },
|
|
508
537
|
);
|
|
509
538
|
}
|
|
510
539
|
|
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);
|