@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.
@@ -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 every request these helpers make carries `bypassBatch`
15
- * (see the three classes at the top of http.js). That is what lets them work
16
- * while a batch is open on the same client: each page is answered from the
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 (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
 
@@ -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? } // string
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
- // bypassBatch: the registry is read by whoever asked, not by whatever batch
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
- return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`);
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
- * bypassBatch: these are out-of-band signals to whoever is waiting, not writes
124
- * to the project. A service reports them from inside its own
125
- * `client.batched()` block, where queueing them would hold every one back
126
- * until submit, deliver none at all if the batch aborts, and take slots in the
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, bypassBatch: true },
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
- requesterId && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
247
- ? { ...payload.data, requesterId }
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 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}` : '';
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
- * Goes over the wire even while a batch is open on the client. A DELETE, but
491
- * not a write: it signals a running service out of band and changes no project
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
- { bypassBatch: true },
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
- 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);