@larc-iu/plaid-client 0.2.0-alpha.7 → 0.2.0-alpha.9
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 +42 -4
- package/package.json +1 -1
- package/src/http.js +193 -39
- package/src/index.js +220 -72
- package/src/provenance.js +248 -27
- package/src/services.js +145 -32
package/src/provenance.js
CHANGED
|
@@ -5,28 +5,53 @@
|
|
|
5
5
|
* Flat scalar keys — the query engine matches flat metadata well, nested
|
|
6
6
|
* objects poorly.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Two axes. ORIGIN is the `prov` key: absent means a trusted person (a
|
|
9
|
+
* verifier) made it; 'inferred' means an algorithm or service did;
|
|
10
|
+
* 'contributed' means a person whose work the project reviews (a
|
|
11
|
+
* contributor) did. TRUST is `provConfirmed`: true once a verifier vouched
|
|
12
|
+
* for the value. Together they give four states:
|
|
10
13
|
*
|
|
11
|
-
* human — no prov keys; a
|
|
14
|
+
* human — no prov keys; a verifier made it.
|
|
12
15
|
* machine — { prov: 'inferred', provSource: '<producer>' };
|
|
13
|
-
* an algorithm/service made it,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* an algorithm/service made it, nobody has vouched.
|
|
17
|
+
* contributed — { prov: 'contributed', provSource: 'user:<id>' };
|
|
18
|
+
* a contributor made it, no verifier has vouched.
|
|
19
|
+
* verified — either origin + { provConfirmed: true }; a verifier
|
|
20
|
+
* confirmed (or edited) it. prov/provSource stay so
|
|
21
|
+
* the origin remains traceable.
|
|
17
22
|
*
|
|
18
23
|
* The write contract every machine writer must follow:
|
|
19
24
|
* 1. Machine writers may freely replace MACHINE (unverified) material.
|
|
20
|
-
* 2. Machine writers must never modify or delete human or
|
|
21
|
-
* material unless explicitly told to overwrite (an explicit,
|
|
22
|
-
* user-facing opt-in — for services, a declared boolean
|
|
23
|
-
* parameter).
|
|
24
|
-
* 3.
|
|
25
|
-
* stamps { provConfirmed: true } (see verifyOnEdit).
|
|
25
|
+
* 2. Machine writers must never modify or delete human, contributed or
|
|
26
|
+
* verified material unless explicitly told to overwrite (an explicit,
|
|
27
|
+
* per-run, user-facing opt-in — for services, a declared boolean
|
|
28
|
+
* `overwrite` parameter). A contributor's work is a person's work.
|
|
29
|
+
* 3. A verifier's edit of machine or contributed material verifies it:
|
|
30
|
+
* the edit also stamps { provConfirmed: true } (see verifyOnEdit).
|
|
31
|
+
* A contributor's edit of anything marks it contributed: the edit
|
|
32
|
+
* merges stampContributed(userId) and drops any earlier confirmation
|
|
33
|
+
* (see contributeOnEdit). Who is a contributor is the PROJECT's call,
|
|
34
|
+
* recorded once for every app (see "Review" below); a service running
|
|
35
|
+
* as a contributor should stamp likewise.
|
|
36
|
+
*
|
|
37
|
+
* REVIEW: which people are contributors. A project records it under the
|
|
38
|
+
* reserved `plaid` config namespace, so every app that writes on a person's
|
|
39
|
+
* behalf reads the same answer:
|
|
40
|
+
*
|
|
41
|
+
* config.plaid.review = { users: [userId, ...], roles: ['writer', ...] }
|
|
42
|
+
*
|
|
43
|
+
* `users` names people whose work is reviewed whatever their role; `roles`
|
|
44
|
+
* names whole project roles ('reader' | 'writer' | 'maintainer'; an admin
|
|
45
|
+
* without an explicit role counts as a maintainer). Either may be absent.
|
|
46
|
+
* Nothing here grants or denies access: the ACL lists stay the permission
|
|
47
|
+
* model, and this only says whose work needs a verifier's look. Apps expose
|
|
48
|
+
* it as a per-member mark on their access screens (readReview, isReviewed,
|
|
49
|
+
* withReviewedUser) and derive the writer's policy from it (writerPolicy).
|
|
26
50
|
*
|
|
27
51
|
* Producer naming: 'service:<serviceId>' for services (use serviceSource),
|
|
28
|
-
* 'rule:<name>' for built-in rule algorithms,
|
|
29
|
-
* 'gloss:doc-frequency'
|
|
52
|
+
* 'rule:<name>' for built-in rule algorithms, 'user:<userId>' for a
|
|
53
|
+
* contributor (use userSource), app-specific ids like 'gloss:doc-frequency'
|
|
54
|
+
* or 'flex-import' otherwise.
|
|
30
55
|
*
|
|
31
56
|
* PREDICTION EXTRAS. A producer may also record how confident it was and
|
|
32
57
|
* what else it considered, in two reserved slots split along the
|
|
@@ -68,14 +93,17 @@ export const PROV = Object.freeze({
|
|
|
68
93
|
probKey: 'provProb',
|
|
69
94
|
/** Open map of producer extras (alternatives, model version, raw scores). */
|
|
70
95
|
detailKey: 'provDetail',
|
|
71
|
-
/**
|
|
96
|
+
/** `prov` for machine-made material. Any value but CONTRIBUTED reads as machine. */
|
|
72
97
|
INFERRED: 'inferred',
|
|
98
|
+
/** `prov` for a contributor's material. */
|
|
99
|
+
CONTRIBUTED: 'contributed',
|
|
73
100
|
});
|
|
74
101
|
|
|
75
|
-
/** The
|
|
102
|
+
/** The four provenance states returned by provState. */
|
|
76
103
|
export const PROV_STATES = Object.freeze({
|
|
77
104
|
HUMAN: 'human',
|
|
78
105
|
MACHINE: 'machine',
|
|
106
|
+
CONTRIBUTED: 'contributed',
|
|
79
107
|
VERIFIED: 'verified',
|
|
80
108
|
});
|
|
81
109
|
|
|
@@ -116,35 +144,228 @@ export const confirmedInferred = (source, extras = {}) => ({
|
|
|
116
144
|
*/
|
|
117
145
|
export const PROV_CONFIRMED = Object.freeze({ [PROV.confirmedKey]: true });
|
|
118
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The metadata fragment a contributor's work carries:
|
|
149
|
+
* { prov: 'contributed', provSource: 'user:<userId>' }.
|
|
150
|
+
* @param {string} userId
|
|
151
|
+
*/
|
|
152
|
+
export const stampContributed = (userId) => ({
|
|
153
|
+
[PROV.key]: PROV.CONTRIBUTED,
|
|
154
|
+
[PROV.sourceKey]: userSource(userId),
|
|
155
|
+
});
|
|
156
|
+
|
|
119
157
|
/**
|
|
120
158
|
* Classify an entity's metadata into one of PROV_STATES.
|
|
121
159
|
* @param {Object|null|undefined} metadata
|
|
122
|
-
* @returns {'human'|'machine'|'verified'}
|
|
160
|
+
* @returns {'human'|'machine'|'contributed'|'verified'}
|
|
123
161
|
*/
|
|
124
162
|
export const provState = (metadata) => {
|
|
125
163
|
if (!metadata || metadata[PROV.key] == null) return PROV_STATES.HUMAN;
|
|
126
|
-
|
|
164
|
+
if (metadata[PROV.confirmedKey]) return PROV_STATES.VERIFIED;
|
|
165
|
+
return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV_STATES.CONTRIBUTED : PROV_STATES.MACHINE;
|
|
127
166
|
};
|
|
128
167
|
|
|
129
168
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
169
|
+
* Where an entity came from, confirmed or not: null for a verifier's own
|
|
170
|
+
* work, PROV.INFERRED for a machine's, PROV.CONTRIBUTED for a contributor's.
|
|
171
|
+
* What a verified entity's tooltip needs, since provState folds both
|
|
172
|
+
* origins into 'verified'.
|
|
173
|
+
* @returns {null|'inferred'|'contributed'}
|
|
174
|
+
*/
|
|
175
|
+
export const provOrigin = (metadata) => {
|
|
176
|
+
if (!metadata || metadata[PROV.key] == null) return null;
|
|
177
|
+
return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV.CONTRIBUTED : PROV.INFERRED;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether this entity is machine-made and not yet verified — the material
|
|
182
|
+
* machine writers may replace. Complement of isProtected.
|
|
133
183
|
*/
|
|
134
184
|
export const isMachine = (metadata) => provState(metadata) === PROV_STATES.MACHINE;
|
|
135
185
|
|
|
136
186
|
/**
|
|
137
187
|
* Whether a machine writer must leave this entity alone (write-contract
|
|
138
|
-
* rule 2): true for human-made and
|
|
188
|
+
* rule 2): true for human-made, contributed and verified material.
|
|
139
189
|
*/
|
|
140
190
|
export const isProtected = (metadata) => !isMachine(metadata);
|
|
141
191
|
|
|
142
192
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
193
|
+
* Whether a verifier still has to look at this entity: machine-made or
|
|
194
|
+
* contributed, and not yet confirmed. What review UIs mark and sweep, and
|
|
195
|
+
* what a verifier's confirmation gesture acts on.
|
|
146
196
|
*/
|
|
147
|
-
export const
|
|
197
|
+
export const needsReview = (metadata) => {
|
|
198
|
+
const s = provState(metadata);
|
|
199
|
+
return s === PROV_STATES.MACHINE || s === PROV_STATES.CONTRIBUTED;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The metadata fragment a VERIFIER's edit of this entity should merge in
|
|
204
|
+
* (write-contract rule 3): PROV_CONFIRMED when the entity needs review,
|
|
205
|
+
* else null (nothing to do).
|
|
206
|
+
*/
|
|
207
|
+
export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRMED : null);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
|
|
211
|
+
* (write-contract rule 3): the contributed stamp, plus provConfirmed: null
|
|
212
|
+
* so an earlier confirmation is dropped (a patch deletes null-valued keys;
|
|
213
|
+
* see mergeMetadata for a full replace). Prediction extras a machine
|
|
214
|
+
* recorded stay, as history.
|
|
215
|
+
* @param {Object|null|undefined} metadata - unused today; the fragment is
|
|
216
|
+
* the same whatever the entity was, and the parameter keeps the shape of
|
|
217
|
+
* verifyOnEdit so callers can swap one for the other
|
|
218
|
+
* @param {string} userId
|
|
219
|
+
*/
|
|
220
|
+
// eslint-disable-next-line no-unused-vars
|
|
221
|
+
export const contributeOnEdit = (metadata, userId) => ({
|
|
222
|
+
...stampContributed(userId),
|
|
223
|
+
[PROV.confirmedKey]: null,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Merge a metadata fragment the way the server's PATCH does — a null value
|
|
228
|
+
* deletes the key — for callers that keep a local copy or send a full
|
|
229
|
+
* replacement (setMetadata). Returns a new object.
|
|
230
|
+
*/
|
|
231
|
+
export const mergeMetadata = (metadata, fragment) => {
|
|
232
|
+
const out = { ...(metadata || {}) };
|
|
233
|
+
for (const [k, v] of Object.entries(fragment || {})) {
|
|
234
|
+
if (v === null) delete out[k];
|
|
235
|
+
else out[k] = v;
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
};
|
|
148
239
|
|
|
149
240
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
150
241
|
export const serviceSource = (serviceId) => `service:${serviceId}`;
|
|
242
|
+
|
|
243
|
+
/** Canonical provSource for a contributor: 'user:<userId>'. */
|
|
244
|
+
export const userSource = (userId) => `user:${userId}`;
|
|
245
|
+
|
|
246
|
+
// ---- review: whose work is reviewed (a project-config norm) ----
|
|
247
|
+
|
|
248
|
+
/** The config key, under the `plaid` namespace, holding the review lists. */
|
|
249
|
+
export const REVIEW_KEY = 'review';
|
|
250
|
+
|
|
251
|
+
/** The project roles a review list may name. */
|
|
252
|
+
export const PROJECT_ROLES = Object.freeze(['reader', 'writer', 'maintainer']);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The project's review lists, normalized: { users: string[], roles: string[] }.
|
|
256
|
+
* @param {Object|null|undefined} config - a project's `config`
|
|
257
|
+
*/
|
|
258
|
+
export const readReview = (config) => {
|
|
259
|
+
const raw = config?.plaid?.[REVIEW_KEY];
|
|
260
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
261
|
+
return { users: list(raw?.users), roles: list(raw?.roles) };
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A person's role in a project from its ACL lists: 'maintainer' | 'writer' |
|
|
266
|
+
* 'reader' | null. An admin with no explicit entry is a maintainer, as the
|
|
267
|
+
* permission model treats them.
|
|
268
|
+
* @param {Object} project - with `maintainers`, `writers`, `readers`
|
|
269
|
+
* @param {string} userId
|
|
270
|
+
* @param {Object} [opts]
|
|
271
|
+
* @param {boolean} [opts.isAdmin]
|
|
272
|
+
*/
|
|
273
|
+
export const projectRole = (project, userId, { isAdmin = false } = {}) => {
|
|
274
|
+
const inList = (l) => Array.isArray(l) && userId != null && l.includes(userId);
|
|
275
|
+
if (inList(project?.maintainers)) return 'maintainer';
|
|
276
|
+
if (inList(project?.writers)) return 'writer';
|
|
277
|
+
if (inList(project?.readers)) return 'reader';
|
|
278
|
+
return isAdmin ? 'maintainer' : null;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Whether this person's work is reviewed in this project: named in
|
|
283
|
+
* review.users, or holding a role named in review.roles.
|
|
284
|
+
*/
|
|
285
|
+
export const isReviewed = (project, userId, { isAdmin = false } = {}) => {
|
|
286
|
+
if (!project || userId == null) return false;
|
|
287
|
+
const { users, roles } = readReview(project.config);
|
|
288
|
+
if (users.includes(userId)) return true;
|
|
289
|
+
const role = projectRole(project, userId, { isAdmin });
|
|
290
|
+
return role != null && roles.includes(role);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The review lists with one person added to or removed from `users`
|
|
295
|
+
* (`roles` untouched). Pure; write the result with
|
|
296
|
+
* projects.setConfig(id, 'plaid', REVIEW_KEY, next).
|
|
297
|
+
*/
|
|
298
|
+
export const withReviewedUser = (review, userId, reviewed) => {
|
|
299
|
+
const { users, roles } = readReview({ plaid: { [REVIEW_KEY]: review } });
|
|
300
|
+
const next = users.filter((u) => u !== userId);
|
|
301
|
+
if (reviewed) next.push(userId);
|
|
302
|
+
return { users: next, roles };
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ---- the writer's policy: what a person's writes carry ----
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* What one writer's writes carry and what their review gestures act on,
|
|
309
|
+
* given who they are: `contributorId` is their user id when their work is
|
|
310
|
+
* reviewed (isReviewed), else null for a verifier. Apps derive this once per
|
|
311
|
+
* document and route every human write through it, so the convention's
|
|
312
|
+
* rule 3 has one implementation.
|
|
313
|
+
*
|
|
314
|
+
* @param {string|null} contributorId
|
|
315
|
+
* @returns {{
|
|
316
|
+
* contributorId: string|null,
|
|
317
|
+
* isContributor: boolean,
|
|
318
|
+
* createStamp: Object|null,
|
|
319
|
+
* editStamp: (metadata: Object) => Object|null,
|
|
320
|
+
* confirmStamp: (metadata: Object) => Object|null,
|
|
321
|
+
* adoptStamp: (source: string, detail?: Object) => Object,
|
|
322
|
+
* reviewable: (metadata: Object) => boolean,
|
|
323
|
+
* reviewableState: (state: string) => boolean,
|
|
324
|
+
* }}
|
|
325
|
+
*/
|
|
326
|
+
export const writerPolicy = (contributorId = null) => {
|
|
327
|
+
const id = contributorId || null;
|
|
328
|
+
const contributor = id != null;
|
|
329
|
+
// Material this writer's review gestures act on: a verifier reviews
|
|
330
|
+
// machine and contributed material; a contributor reviews machine
|
|
331
|
+
// proposals only, since their own vouching is itself a contribution.
|
|
332
|
+
const reviewable = (metadata) => (contributor ? isMachine(metadata) : needsReview(metadata));
|
|
333
|
+
const reviewableState = (state) =>
|
|
334
|
+
contributor
|
|
335
|
+
? state === PROV_STATES.MACHINE
|
|
336
|
+
: state === PROV_STATES.MACHINE || state === PROV_STATES.CONTRIBUTED;
|
|
337
|
+
return Object.freeze({
|
|
338
|
+
contributorId: id,
|
|
339
|
+
isContributor: contributor,
|
|
340
|
+
/** The metadata a NEW entity carries: null for a verifier. */
|
|
341
|
+
createStamp: contributor ? stampContributed(id) : null,
|
|
342
|
+
/**
|
|
343
|
+
* The fragment an EDIT merges over the entity's metadata, or null when
|
|
344
|
+
* there is nothing to merge: a verifier confirms what needs review; a
|
|
345
|
+
* contributor's edit marks the entity contributed, dropping any earlier
|
|
346
|
+
* confirmation.
|
|
347
|
+
*/
|
|
348
|
+
editStamp: (metadata) => (contributor ? contributeOnEdit(metadata, id) : verifyOnEdit(metadata)),
|
|
349
|
+
/**
|
|
350
|
+
* The fragment an explicit confirm gesture merges, or null when there is
|
|
351
|
+
* nothing for this writer to confirm: PROV_CONFIRMED for a verifier, the
|
|
352
|
+
* contributed stamp for a contributor accepting a machine proposal.
|
|
353
|
+
*/
|
|
354
|
+
confirmStamp: (metadata) => {
|
|
355
|
+
if (!reviewable(metadata)) return null;
|
|
356
|
+
return contributor ? contributeOnEdit(metadata, id) : PROV_CONFIRMED;
|
|
357
|
+
},
|
|
358
|
+
/**
|
|
359
|
+
* What an adopted suggestion is written with (a guess or a picked value
|
|
360
|
+
* into an empty cell): born-verified with the suggestion's producer as
|
|
361
|
+
* its source for a verifier; contributed for a contributor, with the
|
|
362
|
+
* producer kept as provDetail.guess. `detail` is the prediction extras.
|
|
363
|
+
*/
|
|
364
|
+
adoptStamp: (source, detail) =>
|
|
365
|
+
contributor
|
|
366
|
+
? { ...stampContributed(id), [PROV.detailKey]: { ...(detail || {}), guess: source } }
|
|
367
|
+
: confirmedInferred(source, { detail }),
|
|
368
|
+
reviewable,
|
|
369
|
+
reviewableState,
|
|
370
|
+
});
|
|
371
|
+
};
|
package/src/services.js
CHANGED
|
@@ -118,24 +118,58 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
118
118
|
const qs = params.toString();
|
|
119
119
|
const channelPath = `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs ? `?${qs}` : ''}`;
|
|
120
120
|
|
|
121
|
-
// The channel
|
|
121
|
+
// The requests this channel is serving, by id, each flagged once a
|
|
122
|
+
// `service_cancel` arrives. The handler reads it as `responseHelper.cancelled`;
|
|
123
|
+
// whether to stop is the handler's decision (a write under way should
|
|
124
|
+
// finish), and the request still ends with whatever it reports.
|
|
125
|
+
const cancelled = new Map();
|
|
126
|
+
|
|
127
|
+
// Beyond `connected` (ignored), the channel carries `service_request` and
|
|
128
|
+
// `service_cancel` events.
|
|
122
129
|
const onChannelEvent = (eventType, payload) => {
|
|
123
130
|
if (!isRunning) return true;
|
|
124
|
-
if (
|
|
131
|
+
if (!payload) return;
|
|
132
|
+
if (eventType === 'service_cancel') {
|
|
133
|
+
if (cancelled.has(payload.requestId)) cancelled.set(payload.requestId, true);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (eventType !== 'service_request') return;
|
|
125
137
|
const requestId = payload.requestId;
|
|
126
138
|
if (!requestId) return;
|
|
139
|
+
cancelled.set(requestId, false);
|
|
140
|
+
const finished = () => cancelled.delete(requestId);
|
|
141
|
+
|
|
142
|
+
// Every service is told who asked (`requesterId`), beside the payload
|
|
143
|
+
// when that is a plain object, and on the helper regardless.
|
|
144
|
+
const requesterId = payload.requesterId || null;
|
|
145
|
+
const data =
|
|
146
|
+
requesterId && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
|
147
|
+
? { ...payload.data, requesterId }
|
|
148
|
+
: payload.data;
|
|
127
149
|
|
|
128
150
|
const responseHelper = {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
151
|
+
requestId,
|
|
152
|
+
requesterId,
|
|
153
|
+
// True once the requester asked for the request to stop.
|
|
154
|
+
get cancelled() {
|
|
155
|
+
return cancelled.get(requestId) === true;
|
|
156
|
+
},
|
|
157
|
+
// Extra fields ride in the progress payload (a chat service sends the
|
|
158
|
+
// reply text so far as `text`).
|
|
159
|
+
progress: (percent, msg, extra) =>
|
|
160
|
+
reportEvent(requestId, { status: 'progress', progress: { percent, message: msg, ...extra } }),
|
|
161
|
+
complete: (data) => {
|
|
162
|
+
finished();
|
|
163
|
+
return reportEvent(requestId, { status: 'completed', data });
|
|
164
|
+
},
|
|
165
|
+
error: (error) => {
|
|
166
|
+
finished();
|
|
167
|
+
return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
|
|
168
|
+
},
|
|
135
169
|
};
|
|
136
170
|
|
|
137
171
|
try {
|
|
138
|
-
onServiceRequest(
|
|
172
|
+
onServiceRequest(data, responseHelper);
|
|
139
173
|
} catch (error) {
|
|
140
174
|
responseHelper.error(error?.message || error);
|
|
141
175
|
}
|
|
@@ -172,6 +206,13 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
172
206
|
* response (no broadcast). Rejects if no service is connected (503), if the
|
|
173
207
|
* service reports an error, or on timeout.
|
|
174
208
|
*
|
|
209
|
+
* The request outlives this call: after a timeout, an abort, or a dropped
|
|
210
|
+
* connection the service goes on, and `attachServiceRequest` collects the
|
|
211
|
+
* result given the request id, which `opts.onAccepted` receives as soon as
|
|
212
|
+
* the server has taken the request. Pass `opts.requestId` (a UUID you mint)
|
|
213
|
+
* to know the id before submitting; submitting an id that names a request
|
|
214
|
+
* you already made rejoins it instead of starting another.
|
|
215
|
+
*
|
|
175
216
|
* @param {Object} client - PlaidClient instance
|
|
176
217
|
* @param {string} projectId - Project UUID
|
|
177
218
|
* @param {string} serviceId - Service ID to request
|
|
@@ -179,9 +220,12 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
179
220
|
* @param {number} [timeout=10000] - Timeout in ms
|
|
180
221
|
* @param {function} [onProgress] - Called with each progress payload {percent, message}
|
|
181
222
|
* @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
|
|
223
|
+
* @param {Object} [opts]
|
|
224
|
+
* @param {string} [opts.requestId] - A client-minted request id (UUID)
|
|
225
|
+
* @param {function} [opts.onAccepted] - Called with the request id once the server has it
|
|
182
226
|
* @returns {Promise<any>} The service's result
|
|
183
227
|
*/
|
|
184
|
-
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal) {
|
|
228
|
+
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
|
|
185
229
|
// Propagate an open logical operation (client.beginOperation) to the
|
|
186
230
|
// service: its writes then fold under the requester's audit-log entry
|
|
187
231
|
// (the Python BaseService adopts the id around process_request). Only for a
|
|
@@ -192,6 +236,73 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
192
236
|
group && data && typeof data === 'object' && !Array.isArray(data)
|
|
193
237
|
? { ...data, operationGroup: { id: group.id, message: group.message } }
|
|
194
238
|
: data;
|
|
239
|
+
const qs = opts.requestId ? `?request-id=${encodeURIComponent(opts.requestId)}` : '';
|
|
240
|
+
return streamServiceRequest(
|
|
241
|
+
client,
|
|
242
|
+
{
|
|
243
|
+
url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
|
|
244
|
+
method: 'POST',
|
|
245
|
+
body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
|
|
246
|
+
onStatus: (response) =>
|
|
247
|
+
response.status === 503 ? new Error(`No live service '${serviceId}' on this project`) : null,
|
|
248
|
+
what: 'Service request',
|
|
249
|
+
},
|
|
250
|
+
timeout,
|
|
251
|
+
onProgress,
|
|
252
|
+
signal,
|
|
253
|
+
opts.onAccepted,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Rejoin a service request made earlier (by this user) and await its result:
|
|
259
|
+
* the latest progress is replayed, then the result comes, or at once if the
|
|
260
|
+
* request already finished. Rejects with an error whose `status` is 404 when
|
|
261
|
+
* the request is unknown or expired (the server keeps a finished request's
|
|
262
|
+
* result for a while, not forever).
|
|
263
|
+
*
|
|
264
|
+
* @param {Object} client - PlaidClient instance
|
|
265
|
+
* @param {string} projectId - Project UUID
|
|
266
|
+
* @param {string} requestId - The request id (from `onAccepted` or your own)
|
|
267
|
+
* @param {number} [timeout=10000] - Timeout in ms
|
|
268
|
+
* @param {function} [onProgress] - Called with each progress payload {percent, message}
|
|
269
|
+
* @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
|
|
270
|
+
* @returns {Promise<any>} The service's result
|
|
271
|
+
*/
|
|
272
|
+
export function attachServiceRequest(client, projectId, requestId, timeout = 10000, onProgress, signal) {
|
|
273
|
+
return streamServiceRequest(
|
|
274
|
+
client,
|
|
275
|
+
{
|
|
276
|
+
url: `${client.baseUrl}/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
|
|
277
|
+
method: 'GET',
|
|
278
|
+
what: 'Attach to service request',
|
|
279
|
+
},
|
|
280
|
+
timeout,
|
|
281
|
+
onProgress,
|
|
282
|
+
signal,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Ask the service to stop a request made earlier (by this user). The request
|
|
288
|
+
* still ends with whatever the service then reports, on the stream of whoever
|
|
289
|
+
* is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
|
|
290
|
+
*
|
|
291
|
+
* @param {Object} client - PlaidClient instance
|
|
292
|
+
* @param {string} projectId - Project UUID
|
|
293
|
+
* @param {string} requestId - The request id
|
|
294
|
+
* @returns {Promise<void>}
|
|
295
|
+
*/
|
|
296
|
+
export function cancelServiceRequest(client, projectId, requestId) {
|
|
297
|
+
return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Open a request stream (submit or attach) and read it to its terminal
|
|
302
|
+
* event: `accepted` names the request, `progress` events go to `onProgress`,
|
|
303
|
+
* and `result` / `error` settle the promise.
|
|
304
|
+
*/
|
|
305
|
+
function streamServiceRequest(client, { url, method, body, onStatus, what }, timeout, onProgress, signal, onAccepted) {
|
|
195
306
|
return new Promise((resolve, reject) => {
|
|
196
307
|
const abortController = new AbortController();
|
|
197
308
|
let settled = false;
|
|
@@ -203,14 +314,14 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
203
314
|
fn(arg);
|
|
204
315
|
};
|
|
205
316
|
const timer = setTimeout(
|
|
206
|
-
() => finish(reject, new Error(
|
|
317
|
+
() => finish(reject, new Error(`${what} timed out after ${timeout}ms`)),
|
|
207
318
|
timeout,
|
|
208
319
|
);
|
|
209
320
|
|
|
210
321
|
// An external signal stops waiting on a long request (a UI's Stop button).
|
|
211
322
|
// Reject with an AbortError so a caller can tell a deliberate stop from a
|
|
212
|
-
// failure. The service is not told: it
|
|
213
|
-
//
|
|
323
|
+
// failure. The service is not told here: it goes on, and its result can
|
|
324
|
+
// still be collected by request id (see cancelServiceRequest to stop it).
|
|
214
325
|
const stop = () => {
|
|
215
326
|
const err = new Error('The service request was stopped');
|
|
216
327
|
err.name = 'AbortError';
|
|
@@ -227,30 +338,30 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
227
338
|
(async () => {
|
|
228
339
|
let response;
|
|
229
340
|
try {
|
|
230
|
-
response = await fetch(
|
|
231
|
-
|
|
232
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
'Content-Type': 'application/json',
|
|
237
|
-
'Accept': 'text/event-stream',
|
|
238
|
-
},
|
|
239
|
-
body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
|
|
240
|
-
signal: abortController.signal,
|
|
341
|
+
response = await fetch(url, {
|
|
342
|
+
method,
|
|
343
|
+
headers: {
|
|
344
|
+
'Authorization': `Bearer ${client.token}`,
|
|
345
|
+
'Content-Type': 'application/json',
|
|
346
|
+
'Accept': 'text/event-stream',
|
|
241
347
|
},
|
|
242
|
-
|
|
348
|
+
...(body === undefined ? {} : { body }),
|
|
349
|
+
signal: abortController.signal,
|
|
350
|
+
});
|
|
243
351
|
} catch (error) {
|
|
244
|
-
if (error.name !== 'AbortError') finish(reject, new Error(
|
|
352
|
+
if (error.name !== 'AbortError') finish(reject, new Error(`${what} could not be sent: ${error.message}`));
|
|
245
353
|
return;
|
|
246
354
|
}
|
|
247
355
|
|
|
248
|
-
|
|
249
|
-
|
|
356
|
+
const special = onStatus ? onStatus(response) : null;
|
|
357
|
+
if (special) {
|
|
358
|
+
finish(reject, special);
|
|
250
359
|
return;
|
|
251
360
|
}
|
|
252
361
|
if (!response.ok) {
|
|
253
|
-
|
|
362
|
+
const err = new Error(`${what} failed: HTTP ${response.status} ${response.statusText}`);
|
|
363
|
+
err.status = response.status;
|
|
364
|
+
finish(reject, err);
|
|
254
365
|
return;
|
|
255
366
|
}
|
|
256
367
|
|
|
@@ -275,13 +386,15 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
275
386
|
dataLine = line.slice(6);
|
|
276
387
|
} else if (line === '' && eventType && dataLine) {
|
|
277
388
|
const payload = transformResponse(JSON.parse(dataLine));
|
|
278
|
-
if (eventType === '
|
|
389
|
+
if (eventType === 'accepted') {
|
|
390
|
+
if (onAccepted) { try { onAccepted(payload.requestId); } catch (_) { /* ignore */ } }
|
|
391
|
+
} else if (eventType === 'progress') {
|
|
279
392
|
if (onProgress) { try { onProgress(payload.progress); } catch (_) { /* ignore */ } }
|
|
280
393
|
} else if (eventType === 'result') {
|
|
281
394
|
finish(resolve, payload.data);
|
|
282
395
|
return;
|
|
283
396
|
} else if (eventType === 'error') {
|
|
284
|
-
finish(reject, new Error(payload?.error ||
|
|
397
|
+
finish(reject, new Error(payload?.error || `${what} failed`));
|
|
285
398
|
return;
|
|
286
399
|
}
|
|
287
400
|
eventType = '';
|
|
@@ -291,7 +404,7 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
291
404
|
}
|
|
292
405
|
finish(reject, new Error('Service closed the connection without a result'));
|
|
293
406
|
} catch (error) {
|
|
294
|
-
if (error.name !== 'AbortError') finish(reject, new Error(
|
|
407
|
+
if (error.name !== 'AbortError') finish(reject, new Error(`${what} stream error: ${error.message}`));
|
|
295
408
|
}
|
|
296
409
|
})();
|
|
297
410
|
});
|