@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.
@@ -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 (a patch deletes null-valued keys;
232
- * see mergeMetadata for a full replace). Prediction extras a machine
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
- requesterId && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
244
- ? { ...payload.data, requesterId }
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 qs = opts.requestId ? `?request-id=${encodeURIComponent(opts.requestId)}` : '';
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
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
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
- console.warn('SSE connection error:', error);
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 (OPAQUE_KEYS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) {
48
+ if (isOpaque(key, value)) {
43
49
  transformed[newKey] = value;
44
50
  } else {
45
51
  transformed[newKey] = transformRequest(value);