@spooled/sdk 1.2.0 → 1.3.0

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/dist/index.cjs CHANGED
@@ -1,7 +1,5 @@
1
1
  'use strict';
2
2
 
3
- var crypto = require('crypto');
4
- var buffer = require('buffer');
5
3
  var grpc2 = require('@grpc/grpc-js');
6
4
  var protoLoader = require('@grpc/proto-loader');
7
5
  var fs = require('fs');
@@ -50,10 +48,10 @@ var DEFAULT_CONFIG = {
50
48
  successThreshold: 3,
51
49
  timeout: 3e4
52
50
  },
53
- userAgent: "@spooled/sdk-nodejs/1.2.0",
51
+ userAgent: "@spooled/sdk-nodejs/1.3.0",
54
52
  autoRefreshToken: true
55
53
  };
56
- var SDK_VERSION = "1.2.0";
54
+ var SDK_VERSION = "1.3.0";
57
55
  var API_VERSION = "v1";
58
56
  var API_BASE_PATH = `/api/${API_VERSION}`;
59
57
  function normalizeCredential(value) {
@@ -110,9 +108,9 @@ function resolveConfig(options) {
110
108
  };
111
109
  }
112
110
  function validateConfig(config) {
113
- if (!config.apiKey && !config.accessToken) {
111
+ if (!config.apiKey && !config.accessToken && !config.adminKey) {
114
112
  throw new Error(
115
- "SpooledClient requires either apiKey or accessToken for authentication"
113
+ "SpooledClient requires either apiKey or accessToken for authentication (or adminKey for admin-only use)"
116
114
  );
117
115
  }
118
116
  if (config.apiKey && !config.apiKey.startsWith("sp_") && !config.apiKey.startsWith("sk_")) {
@@ -175,9 +173,15 @@ var SpooledError = class extends Error {
175
173
  Error.captureStackTrace(this, this.constructor);
176
174
  }
177
175
  }
178
- /** Whether this error is retryable */
176
+ /**
177
+ * Whether this error is retryable.
178
+ *
179
+ * Only 408 (Request Timeout) is retryable among the plain 4xx statuses: the
180
+ * server gave up waiting for the request, so it never ran. Subclasses
181
+ * override this (5xx, network, timeout, rate limit).
182
+ */
179
183
  isRetryable() {
180
- return false;
184
+ return this.statusCode === 408;
181
185
  }
182
186
  /** Convert to plain object for logging */
183
187
  toJSON() {
@@ -216,8 +220,8 @@ var ConflictError = class extends SpooledError {
216
220
  }
217
221
  };
218
222
  var ValidationError = class extends SpooledError {
219
- constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId) {
220
- super(message, 400, code, details, requestId);
223
+ constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId, statusCode = 400) {
224
+ super(message, statusCode, code, details, requestId);
221
225
  this.name = "ValidationError";
222
226
  }
223
227
  };
@@ -227,6 +231,7 @@ var PayloadTooLargeError = class extends SpooledError {
227
231
  this.name = "PayloadTooLargeError";
228
232
  }
229
233
  };
234
+ var QUOTA_MESSAGE = /\blimit reached\b/i;
230
235
  var RateLimitError = class extends SpooledError {
231
236
  /** Rate limit information */
232
237
  rateLimitInfo;
@@ -235,8 +240,27 @@ var RateLimitError = class extends SpooledError {
235
240
  this.name = "RateLimitError";
236
241
  this.rateLimitInfo = rateLimitInfo;
237
242
  }
243
+ /**
244
+ * True when this 429 is a plan quota or limit (daily job cap, active jobs,
245
+ * queue/worker/webhook caps) rather than per-second rate limiting.
246
+ *
247
+ * A quota does not clear for minutes or hours, so retrying it only blocks the
248
+ * caller for the whole backoff and, via the circuit breaker, takes every
249
+ * other call on the client down with it. It is never retried and never
250
+ * counted as a circuit breaker failure.
251
+ *
252
+ * Detection: the `QUOTA_EXCEEDED` code, or, for older plan-limit paths that
253
+ * answer with plain text wrapped as `RATE_LIMIT_EXCEEDED`, a "... limit
254
+ * reached" message that carries no `Retry-After`.
255
+ */
256
+ get isQuota() {
257
+ if (this.code === "QUOTA_EXCEEDED") {
258
+ return true;
259
+ }
260
+ return this.rateLimitInfo.retryAfter === void 0 && QUOTA_MESSAGE.test(this.message);
261
+ }
238
262
  isRetryable() {
239
- return true;
263
+ return !this.isQuota;
240
264
  }
241
265
  /** Get the number of seconds to wait before retrying */
242
266
  getRetryAfter() {
@@ -296,6 +320,20 @@ var CircuitBreakerOpenError = class extends SpooledError {
296
320
  return false;
297
321
  }
298
322
  };
323
+ var UnsupportedOperationError = class extends SpooledError {
324
+ /** The SDK method that is not supported, e.g. `ingest.github` */
325
+ operation;
326
+ constructor(operation, reason) {
327
+ super(
328
+ `${operation} is not supported by the Spooled API: ${reason}`,
329
+ 0,
330
+ "UNSUPPORTED_OPERATION",
331
+ { operation }
332
+ );
333
+ this.name = "UnsupportedOperationError";
334
+ this.operation = operation;
335
+ }
336
+ };
299
337
  function parseRateLimitHeaders(headers) {
300
338
  const info = {};
301
339
  const retryAfter = headers.get("Retry-After");
@@ -367,6 +405,10 @@ async function createErrorFromResponse(response) {
367
405
  switch (response.status) {
368
406
  case 400:
369
407
  return new ValidationError(message, code, details, requestId);
408
+ // A missing or mistyped JSON field is a 422 from the API's extractor; it is
409
+ // the same class of caller error as a 400 field-rule failure.
410
+ case 422:
411
+ return new ValidationError(message, code, details, requestId, 422);
370
412
  case 401:
371
413
  return new AuthenticationError(message, code, details, requestId);
372
414
  case 403:
@@ -595,8 +637,12 @@ var HttpClient = class {
595
637
  authToken;
596
638
  /** Optional function to mint a fresh access token (set when auto-refresh is enabled) */
597
639
  refreshTokenFn = null;
598
- /** Guard to prevent a refresh request from recursively triggering another refresh */
599
- isRefreshing = false;
640
+ /**
641
+ * The refresh currently in flight, shared by every request that gets a 401
642
+ * meanwhile. A boolean guard here made the second concurrent 401 skip the
643
+ * refresh and fail even though the token was about to be replaced.
644
+ */
645
+ refreshInFlight = null;
600
646
  constructor(config, circuitBreaker) {
601
647
  this.config = config;
602
648
  this.circuitBreaker = circuitBreaker;
@@ -618,6 +664,30 @@ var HttpClient = class {
618
664
  setRefreshTokenFn(fn) {
619
665
  this.refreshTokenFn = fn;
620
666
  }
667
+ /**
668
+ * Refresh the access token once for all callers.
669
+ *
670
+ * Resolves true when a new token is installed, false when the refresh failed
671
+ * (the caller then surfaces its original 401).
672
+ */
673
+ refreshAuthToken() {
674
+ if (!this.refreshInFlight) {
675
+ const refresh = this.refreshTokenFn;
676
+ this.refreshInFlight = (async () => {
677
+ try {
678
+ this.setAuthToken(await refresh());
679
+ return true;
680
+ } catch (refreshError) {
681
+ const message = refreshError instanceof Error ? refreshError.message : "unknown";
682
+ this.debug?.("Token refresh failed", { error: message });
683
+ return false;
684
+ } finally {
685
+ this.refreshInFlight = null;
686
+ }
687
+ })();
688
+ }
689
+ return this.refreshInFlight;
690
+ }
621
691
  /**
622
692
  * Build full URL with query parameters
623
693
  */
@@ -693,6 +763,7 @@ var HttpClient = class {
693
763
  */
694
764
  async executeRequest(url, options, hasRefreshed = false) {
695
765
  const method = options.method ?? "GET";
766
+ const tokenUsed = this.authToken;
696
767
  const headers = this.buildHeaders(options.headers);
697
768
  const timeout = options.timeout ?? this.config.timeout;
698
769
  const init = {
@@ -707,6 +778,9 @@ var HttpClient = class {
707
778
  init.body = JSON.stringify(body);
708
779
  }
709
780
  }
781
+ if (init.body === void 0) {
782
+ headers.delete("Content-Type");
783
+ }
710
784
  this.debug?.(`${method} ${url}`, { timeout, hasBody: !!options.body });
711
785
  let response;
712
786
  try {
@@ -728,20 +802,9 @@ var HttpClient = class {
728
802
  }
729
803
  this.debug?.(`Response: ${response.status}`, { url });
730
804
  if (!response.ok) {
731
- if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !this.isRefreshing) {
732
- let refreshed = false;
733
- this.isRefreshing = true;
734
- try {
735
- const newToken = await this.refreshTokenFn();
736
- this.setAuthToken(newToken);
737
- refreshed = true;
738
- } catch (refreshError) {
739
- const message = refreshError instanceof Error ? refreshError.message : "unknown";
740
- this.debug?.("Token refresh failed", { error: message });
741
- } finally {
742
- this.isRefreshing = false;
743
- }
744
- if (refreshed) {
805
+ if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !options.skipAuthRefresh) {
806
+ const alreadyRefreshed = this.authToken !== tokenUsed;
807
+ if (alreadyRefreshed || await this.refreshAuthToken()) {
745
808
  return this.executeRequest(url, options, true);
746
809
  }
747
810
  }
@@ -803,22 +866,26 @@ var HttpClient = class {
803
866
  /**
804
867
  * Resolve the retry configuration for a request.
805
868
  *
806
- * Idempotent methods (GET/PUT/DELETE) and any request carrying an
807
- * idempotency key retry normally. Non-idempotent methods (POST/PATCH)
808
- * without an idempotency key must NOT be retried on ambiguous failures
869
+ * Idempotent methods (GET/PUT/DELETE), any request carrying an idempotency
870
+ * key and any request marked `idempotent` retry normally. Non-idempotent
871
+ * methods (POST/PATCH) otherwise must NOT be retried on ambiguous failures
809
872
  * (network/timeout/5xx) — doing so risks duplicate jobs, double claims,
810
- * etc. They are still retried on 429, which is rejected before the
811
- * server processes the request and is therefore side-effect-free.
873
+ * etc. They are still retried on a rate-limit 429, which is rejected before
874
+ * the server processes the request and is therefore side-effect-free. A
875
+ * quota 429 is never retried: it does not clear for hours.
812
876
  */
813
877
  resolveRetryConfig(method, options) {
814
878
  const base = this.config.retry;
815
- if (IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
879
+ if (options.idempotent || IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
816
880
  return base;
817
881
  }
818
882
  return {
819
883
  ...base,
820
884
  retryOn: (error, attempt) => {
821
885
  if (error instanceof RateLimitError) {
886
+ if (error.isQuota) {
887
+ return false;
888
+ }
822
889
  return base.retryOn ? base.retryOn(error, attempt) : true;
823
890
  }
824
891
  return false;
@@ -1049,13 +1116,17 @@ var AuthResource = class {
1049
1116
  * Exchange API key for JWT tokens
1050
1117
  */
1051
1118
  async login(params) {
1052
- return this.http.post("/auth/login", params);
1119
+ return this.http.post("/auth/login", params, {
1120
+ skipAuthRefresh: true
1121
+ });
1053
1122
  }
1054
1123
  /**
1055
1124
  * Refresh access token using refresh token
1056
1125
  */
1057
1126
  async refresh(params) {
1058
- return this.http.post("/auth/refresh", params);
1127
+ return this.http.post("/auth/refresh", params, {
1128
+ skipAuthRefresh: true
1129
+ });
1059
1130
  }
1060
1131
  /**
1061
1132
  * Logout and revoke the refresh token server-side.
@@ -1126,6 +1197,21 @@ function mapValidate(raw) {
1126
1197
  };
1127
1198
  }
1128
1199
 
1200
+ // src/utils/failure.ts
1201
+ var MAX_FAILURE_MESSAGE_CHARS = 2048;
1202
+ var DEFAULT_FAILURE_MESSAGE = "Job failed";
1203
+ function coerceFailureMessage(message) {
1204
+ const text = typeof message === "string" ? message : message == null ? "" : `${message}`;
1205
+ if (text === "") {
1206
+ return DEFAULT_FAILURE_MESSAGE;
1207
+ }
1208
+ if (text.length <= MAX_FAILURE_MESSAGE_CHARS) {
1209
+ return text;
1210
+ }
1211
+ const codePoints = Array.from(text);
1212
+ return codePoints.length <= MAX_FAILURE_MESSAGE_CHARS ? text : codePoints.slice(0, MAX_FAILURE_MESSAGE_CHARS).join("");
1213
+ }
1214
+
1129
1215
  // src/resources/jobs.ts
1130
1216
  var JobsResource = class {
1131
1217
  constructor(http) {
@@ -1142,7 +1228,9 @@ var JobsResource = class {
1142
1228
  * Create a new job
1143
1229
  */
1144
1230
  async create(params) {
1145
- return this.http.post("/jobs", params);
1231
+ return this.http.post("/jobs", params, {
1232
+ idempotent: params.idempotencyKey ? true : void 0
1233
+ });
1146
1234
  }
1147
1235
  /**
1148
1236
  * Create a job and return the full job object
@@ -1226,26 +1314,43 @@ var JobsResource = class {
1226
1314
  }
1227
1315
  /**
1228
1316
  * Complete a job (worker ack)
1317
+ *
1318
+ * Lease-fenced and idempotent on the server, so a network error, timeout or
1319
+ * 5xx is retried: a lost ack would otherwise re-run a finished job.
1229
1320
  */
1230
1321
  async complete(id, params) {
1231
- return this.http.post(`/jobs/${id}/complete`, params);
1322
+ return this.http.post(
1323
+ `/jobs/${id}/complete`,
1324
+ params,
1325
+ { idempotent: true }
1326
+ );
1232
1327
  }
1233
1328
  /**
1234
1329
  * Fail a job (worker nack)
1330
+ *
1331
+ * `params.error` is made acceptable first: empty text becomes "Job failed"
1332
+ * and text over 2048 characters is truncated, because the API rejects both
1333
+ * with a 400 that would leave the job processing until its lease expires.
1334
+ * Retried like {@link JobsResource.complete}.
1235
1335
  */
1236
1336
  async fail(id, params) {
1237
1337
  return this.http.post(
1238
1338
  `/jobs/${id}/fail`,
1239
- params
1339
+ { ...params, error: coerceFailureMessage(params.error) },
1340
+ { idempotent: true }
1240
1341
  );
1241
1342
  }
1242
1343
  /**
1243
1344
  * Extend job lease (heartbeat)
1345
+ *
1346
+ * Retried like {@link JobsResource.complete}: with the default 30 s lease and
1347
+ * 15 s heartbeat, one lost heartbeat leaves no slack.
1244
1348
  */
1245
1349
  async heartbeat(id, params) {
1246
1350
  return this.http.post(
1247
1351
  `/jobs/${id}/heartbeat`,
1248
- params
1352
+ params,
1353
+ { idempotent: true }
1249
1354
  );
1250
1355
  }
1251
1356
  // DLQ operations (private implementations)
@@ -1341,9 +1446,17 @@ var QueuesResource = class {
1341
1446
  }
1342
1447
  /**
1343
1448
  * Delete a queue configuration
1344
- */
1345
- async delete(name) {
1346
- await this.http.delete(`/queues/${encodeURIComponent(name)}`);
1449
+ *
1450
+ * Without `deleteJobs` the API only deletes an explicit queue config of an
1451
+ * idle queue, and answers 409 otherwise: a queue that exists only because
1452
+ * jobs were posted to it has no config to delete, and a queue with pending
1453
+ * or processing jobs is not idle. Pass `{ deleteJobs: true }` to delete the
1454
+ * queue's jobs (and DLQ rows) along with it.
1455
+ */
1456
+ async delete(name, options) {
1457
+ await this.http.delete(`/queues/${encodeURIComponent(name)}`, {
1458
+ params: options?.deleteJobs ? { deleteJobs: true } : void 0
1459
+ });
1347
1460
  }
1348
1461
  };
1349
1462
 
@@ -1372,9 +1485,13 @@ var WorkersResource = class {
1372
1485
  }
1373
1486
  /**
1374
1487
  * Send worker heartbeat
1488
+ *
1489
+ * Idempotent on the server, so network errors, timeouts and 5xx are retried.
1375
1490
  */
1376
1491
  async heartbeat(id, params) {
1377
- await this.http.post(`/workers/${id}/heartbeat`, params);
1492
+ await this.http.post(`/workers/${id}/heartbeat`, params, {
1493
+ idempotent: true
1494
+ });
1378
1495
  }
1379
1496
  /**
1380
1497
  * Deregister a worker
@@ -1523,7 +1640,7 @@ var WorkflowsResource = class {
1523
1640
  const jobs = await this.listWorkflowJobs(workflowId);
1524
1641
  return jobs.map((job) => ({
1525
1642
  jobId: job.id,
1526
- key: job.key,
1643
+ ...job.key !== void 0 && { key: job.key },
1527
1644
  status: job.status
1528
1645
  }));
1529
1646
  }
@@ -1581,7 +1698,10 @@ function mapWorkflowDetailJobs(detail) {
1581
1698
  return {
1582
1699
  id,
1583
1700
  workflowId: String(job.workflowId ?? workflowId),
1584
- key: String(job.key ?? ""),
1701
+ // The API does not return the creation-time key (jobs store only a
1702
+ // workflow step), so there is nothing to put here unless a future
1703
+ // version sends one. Never fabricate "".
1704
+ ...typeof job.key === "string" && job.key !== "" && { key: job.key },
1585
1705
  queueName: String(job.queueName ?? job.queue ?? ""),
1586
1706
  status: job.status,
1587
1707
  payload: job.payload ?? {},
@@ -1793,19 +1913,18 @@ var OrganizationsResource = class {
1793
1913
  /**
1794
1914
  * Clear/delete the webhook token for the current organization.
1795
1915
  *
1796
- * POST /organizations/webhook-token/clear requires `{ confirm: true }`.
1797
- * An empty body 422s. The API currently rejects the operation and tells
1798
- * callers to use regenerateWebhookToken() instead.
1799
- *
1800
- * @example
1801
- * ```typescript
1802
- * await client.organizations.clearWebhookToken();
1803
- * ```
1916
+ * @deprecated The API refuses this operation on purpose (an org without a
1917
+ * webhook token would have an open public ingestion endpoint) and always
1918
+ * answers with an error. This method now throws
1919
+ * {@link UnsupportedOperationError} without a network call; rotate the token
1920
+ * with {@link OrganizationsResource.regenerateWebhookToken} instead. It will
1921
+ * be removed in the next major release.
1804
1922
  */
1805
1923
  async clearWebhookToken() {
1806
- await this.http.post("/organizations/webhook-token/clear", {
1807
- confirm: true
1808
- });
1924
+ throw new UnsupportedOperationError(
1925
+ "organizations.clearWebhookToken",
1926
+ "clearing the webhook token is not allowed; use organizations.regenerateWebhookToken() to rotate it"
1927
+ );
1809
1928
  }
1810
1929
  };
1811
1930
 
@@ -2013,24 +2132,8 @@ var AdminResource = class {
2013
2132
  });
2014
2133
  }
2015
2134
  };
2016
- function toBytes(body) {
2017
- if (typeof body === "string") {
2018
- return buffer.Buffer.from(body, "utf8");
2019
- }
2020
- if (body instanceof Uint8Array) {
2021
- return body;
2022
- }
2023
- return new Uint8Array(body);
2024
- }
2025
- function githubSignature(secret, body) {
2026
- const hex = crypto.createHmac("sha256", secret).update(body).digest("hex");
2027
- return `sha256=${hex}`;
2028
- }
2029
- function stripeSignature(secret, body, timestamp) {
2030
- const payload = `${timestamp}.${buffer.Buffer.from(body).toString("utf8")}`;
2031
- const hex = crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");
2032
- return `t=${timestamp},v1=${hex}`;
2033
- }
2135
+
2136
+ // src/resources/webhook-ingestion.ts
2034
2137
  var WebhookIngestionResource = class {
2035
2138
  constructor(http) {
2036
2139
  this.http = http;
@@ -2038,66 +2141,32 @@ var WebhookIngestionResource = class {
2038
2141
  /**
2039
2142
  * Ingest a GitHub webhook.
2040
2143
  *
2041
- * POST /api/v1/webhooks/{org_id}/github
2042
- */
2043
- async github(orgId, body, options) {
2044
- const bytes = toBytes(body);
2045
- const signature = options.signature ?? (options.secret ? githubSignature(options.secret, bytes) : void 0);
2046
- if (!signature) {
2047
- throw new ValidationError(
2048
- "GitHub webhook signature is required (provide `signature` or `secret`)"
2049
- );
2050
- }
2051
- const headers = {
2052
- "Content-Type": "application/json",
2053
- "X-GitHub-Event": options.githubEvent,
2054
- "X-Hub-Signature-256": signature
2055
- };
2056
- if (options.webhookToken) {
2057
- headers["X-Webhook-Token"] = options.webhookToken;
2058
- }
2059
- if (options.forwardedProto) {
2060
- headers["X-Forwarded-Proto"] = options.forwardedProto;
2061
- }
2062
- await this.http.request(`/webhooks/${orgId}/github`, {
2063
- method: "POST",
2064
- rawBody: bytes,
2065
- headers,
2066
- skipRequestConversion: true,
2067
- skipResponseConversion: true
2068
- });
2144
+ * @deprecated The API has no `/webhooks/{org_id}/github` route (it always
2145
+ * answered 404). This method now throws {@link UnsupportedOperationError}
2146
+ * without a network call; point the GitHub webhook at `ingest.custom` /
2147
+ * `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
2148
+ * next major release.
2149
+ */
2150
+ async github(_orgId, _body, _options) {
2151
+ throw new UnsupportedOperationError(
2152
+ "ingest.github",
2153
+ "the API has no GitHub webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
2154
+ );
2069
2155
  }
2070
2156
  /**
2071
2157
  * Ingest a Stripe webhook.
2072
2158
  *
2073
- * POST /api/v1/webhooks/{org_id}/stripe
2074
- */
2075
- async stripe(orgId, body, options = {}) {
2076
- const bytes = toBytes(body);
2077
- const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
2078
- const signature = options.signature ?? (options.secret ? stripeSignature(options.secret, bytes, timestamp) : void 0);
2079
- if (!signature) {
2080
- throw new ValidationError(
2081
- "Stripe webhook signature is required (provide `signature` or `secret`)"
2082
- );
2083
- }
2084
- const headers = {
2085
- "Content-Type": "application/json",
2086
- "Stripe-Signature": signature
2087
- };
2088
- if (options.webhookToken) {
2089
- headers["X-Webhook-Token"] = options.webhookToken;
2090
- }
2091
- if (options.forwardedProto) {
2092
- headers["X-Forwarded-Proto"] = options.forwardedProto;
2093
- }
2094
- await this.http.request(`/webhooks/${orgId}/stripe`, {
2095
- method: "POST",
2096
- rawBody: bytes,
2097
- headers,
2098
- skipRequestConversion: true,
2099
- skipResponseConversion: true
2100
- });
2159
+ * @deprecated The API has no `/webhooks/{org_id}/stripe` route (it always
2160
+ * answered 404). This method now throws {@link UnsupportedOperationError}
2161
+ * without a network call; point the Stripe webhook at `ingest.custom` /
2162
+ * `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
2163
+ * next major release.
2164
+ */
2165
+ async stripe(_orgId, _body, _options = {}) {
2166
+ throw new UnsupportedOperationError(
2167
+ "ingest.stripe",
2168
+ "the API has no Stripe webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
2169
+ );
2101
2170
  }
2102
2171
  /**
2103
2172
  * Ingest a custom webhook.
@@ -2461,6 +2530,26 @@ function isAuthFailureEvent(event) {
2461
2530
  }
2462
2531
 
2463
2532
  // src/realtime/sse.ts
2533
+ var SSE_EVENT_NAMES = [
2534
+ "job.created",
2535
+ "job.status",
2536
+ "job.completed",
2537
+ "job.failed",
2538
+ "queue.stats",
2539
+ "worker.heartbeat",
2540
+ "worker.registered",
2541
+ "worker.deregistered",
2542
+ "system.health",
2543
+ "ping",
2544
+ "error"
2545
+ ];
2546
+ var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
2547
+ "completed",
2548
+ "failed",
2549
+ "deadletter",
2550
+ "cancelled"
2551
+ ]);
2552
+ var STABLE_CONNECTION_MS = 3e4;
2464
2553
  var SseRealtimeClient = class {
2465
2554
  options;
2466
2555
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2469,6 +2558,12 @@ var SseRealtimeClient = class {
2469
2558
  reconnectAttempts = 0;
2470
2559
  reconnectTimer = null;
2471
2560
  filter = null;
2561
+ /** When the current connection opened (for the stable-connection check). */
2562
+ connectedAt = null;
2563
+ /** The server finished this stream on purpose (terminal job); do not reconnect. */
2564
+ streamEnded = false;
2565
+ /** The server rejected our token; mint a fresh one for the next connect. */
2566
+ forceTokenRefresh = false;
2472
2567
  // Event handlers
2473
2568
  eventHandlers = /* @__PURE__ */ new Map();
2474
2569
  allEventsHandlers = /* @__PURE__ */ new Set();
@@ -2507,6 +2602,7 @@ var SseRealtimeClient = class {
2507
2602
  return;
2508
2603
  }
2509
2604
  this.filter = filter || null;
2605
+ this.streamEnded = false;
2510
2606
  this.setState("connecting");
2511
2607
  const sseUrl = this.buildSseUrl(filter);
2512
2608
  this.options.debug(`Connecting to SSE: ${sseUrl}`);
@@ -2526,7 +2622,9 @@ var SseRealtimeClient = class {
2526
2622
  }
2527
2623
  let token;
2528
2624
  try {
2529
- token = await this.options.tokenProvider(this.reconnectAttempts > 0);
2625
+ const force = this.forceTokenRefresh;
2626
+ this.forceTokenRefresh = false;
2627
+ token = await this.options.tokenProvider(force);
2530
2628
  } catch (error) {
2531
2629
  this.setState("disconnected");
2532
2630
  throw new Error(`Failed to acquire realtime token: ${error}`);
@@ -2546,14 +2644,22 @@ var SseRealtimeClient = class {
2546
2644
  this.eventSource.onopen = () => {
2547
2645
  this.options.debug("SSE connected");
2548
2646
  this.setState("connected");
2549
- this.reconnectAttempts = 0;
2647
+ this.connectedAt = Date.now();
2550
2648
  resolve();
2551
2649
  };
2552
2650
  this.eventSource.onmessage = (event) => {
2553
2651
  this.handleMessage(event.data);
2554
2652
  };
2555
- this.eventSource.onerror = () => {
2653
+ for (const name of SSE_EVENT_NAMES) {
2654
+ this.eventSource.addEventListener(name, (event) => {
2655
+ if (typeof event?.data === "string") this.handleMessage(event.data);
2656
+ });
2657
+ }
2658
+ this.eventSource.onerror = (event) => {
2556
2659
  this.options.debug("SSE error");
2660
+ if (event?.code === 401 || event?.code === 403) {
2661
+ this.forceTokenRefresh = true;
2662
+ }
2557
2663
  if (this.state === "connecting") {
2558
2664
  this.setState("disconnected");
2559
2665
  if (this.eventSource) {
@@ -2585,6 +2691,8 @@ var SseRealtimeClient = class {
2585
2691
  }
2586
2692
  this.setState("disconnected");
2587
2693
  this.filter = null;
2694
+ this.connectedAt = null;
2695
+ this.streamEnded = false;
2588
2696
  }
2589
2697
  /**
2590
2698
  * Reconnect with a new filter (SSE requires reconnection to change subscriptions)
@@ -2632,7 +2740,7 @@ var SseRealtimeClient = class {
2632
2740
  return `${this.options.baseUrl}/api/v1/events/jobs/${encodeURIComponent(filter.jobId)}`;
2633
2741
  }
2634
2742
  if (filter?.queueName) {
2635
- return `${this.options.baseUrl}/api/v1/events/queues/${encodeURIComponent(filter.queueName)}`;
2743
+ return `${this.options.baseUrl}/api/v1/events?queue=${encodeURIComponent(filter.queueName)}`;
2636
2744
  }
2637
2745
  return `${this.options.baseUrl}/api/v1/events`;
2638
2746
  }
@@ -2649,6 +2757,9 @@ var SseRealtimeClient = class {
2649
2757
  parsed.type = eventType;
2650
2758
  const event = parsed;
2651
2759
  this.options.debug(`Received SSE event: ${eventType}`, event);
2760
+ if (this.filter?.jobId && isStreamEndingEvent(eventType, parsed.data)) {
2761
+ this.streamEnded = true;
2762
+ }
2652
2763
  const handlers = this.eventHandlers.get(eventType);
2653
2764
  if (handlers) {
2654
2765
  const eventData = event.data;
@@ -2676,6 +2787,15 @@ var SseRealtimeClient = class {
2676
2787
  this.eventSource.close();
2677
2788
  this.eventSource = null;
2678
2789
  }
2790
+ if (this.streamEnded) {
2791
+ this.connectedAt = null;
2792
+ this.setState("disconnected");
2793
+ return;
2794
+ }
2795
+ if (this.connectedAt !== null && Date.now() - this.connectedAt >= STABLE_CONNECTION_MS) {
2796
+ this.reconnectAttempts = 0;
2797
+ }
2798
+ this.connectedAt = null;
2679
2799
  if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
2680
2800
  this.scheduleReconnect();
2681
2801
  } else {
@@ -2707,6 +2827,19 @@ var SseRealtimeClient = class {
2707
2827
  }, delay);
2708
2828
  }
2709
2829
  };
2830
+ function isStreamEndingEvent(eventType, data) {
2831
+ if (eventType === "error" || eventType === "job.completed") {
2832
+ return true;
2833
+ }
2834
+ const record = data ?? {};
2835
+ if (eventType === "job.failed") {
2836
+ return record.willRetry === false;
2837
+ }
2838
+ if (eventType === "job.status_changed" || eventType === "job.status") {
2839
+ return typeof record.newStatus === "string" && TERMINAL_JOB_STATUSES.has(record.newStatus);
2840
+ }
2841
+ return false;
2842
+ }
2710
2843
 
2711
2844
  // src/realtime/index.ts
2712
2845
  var SpooledRealtime = class {
@@ -2893,6 +3026,11 @@ function clearProtoCache() {
2893
3026
  cachedDefinition = null;
2894
3027
  }
2895
3028
 
3029
+ // src/grpc/encode.ts
3030
+ function encodeFailRequest(params) {
3031
+ return { ...params, retry: params.retry ?? true };
3032
+ }
3033
+
2896
3034
  // src/grpc/streaming.ts
2897
3035
  async function* asyncIterableFromStream(stream, options) {
2898
3036
  const { signal, onConnected, onError, onEnd } = options ?? {};
@@ -2965,7 +3103,9 @@ function createProcessJobsStream(call, options) {
2965
3103
  }
2966
3104
  return {
2967
3105
  send(request) {
2968
- call.write(request);
3106
+ call.write(
3107
+ request.fail ? { ...request, fail: encodeFailRequest(request.fail) } : request
3108
+ );
2969
3109
  },
2970
3110
  receive() {
2971
3111
  return new AsyncIterableStream(call, { onError, onEnd });
@@ -3153,12 +3293,19 @@ function mapGrpcError(error, timeoutMs) {
3153
3293
  return new ServerError(message, 500, "GRPC_ERROR");
3154
3294
  }
3155
3295
  }
3296
+ function grpcHost(address) {
3297
+ if (address.startsWith("[")) {
3298
+ const end = address.indexOf("]");
3299
+ return end === -1 ? address : address.slice(1, end);
3300
+ }
3301
+ return address.split(":").length > 2 ? address : address.split(":")[0];
3302
+ }
3156
3303
  function shouldUseTls(address, explicitTls) {
3157
3304
  if (explicitTls !== void 0) {
3158
3305
  return explicitTls;
3159
3306
  }
3160
- const host = address.split(":")[0].toLowerCase();
3161
- return host !== "localhost" && host !== "127.0.0.1" && !host.startsWith("[::1]");
3307
+ const host = grpcHost(address).toLowerCase();
3308
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
3162
3309
  }
3163
3310
  var GrpcQueueOperations = class {
3164
3311
  constructor(client, metadata, timeoutMs) {
@@ -3210,7 +3357,7 @@ var GrpcQueueOperations = class {
3210
3357
  return promisify(
3211
3358
  this.client,
3212
3359
  this.client.Fail,
3213
- params,
3360
+ encodeFailRequest(params),
3214
3361
  this.metadata,
3215
3362
  this.timeoutMs
3216
3363
  );
@@ -3600,6 +3747,13 @@ var SpooledClient = class _SpooledClient {
3600
3747
  /**
3601
3748
  * Exchange the API key for a JWT via /auth/login and cache the result.
3602
3749
  *
3750
+ * The JWT belongs to the realtime transport only. It must NOT replace the
3751
+ * REST bearer: an API-key client has no refresh token wired into its HTTP
3752
+ * layer, so after the JWT's 24 h lifetime every REST call (claim, complete,
3753
+ * heartbeat, create) would have failed with 401 for good. REST keeps using
3754
+ * the API key; realtime re-mints its own JWT near expiry (see
3755
+ * `getJwtToken`).
3756
+ *
3603
3757
  * Concurrent callers share a single in-flight login so a burst of reconnects
3604
3758
  * (e.g. WebSocket and SSE at once) issues just one request.
3605
3759
  */
@@ -3609,9 +3763,6 @@ var SpooledClient = class _SpooledClient {
3609
3763
  }
3610
3764
  this.jwtLoginPromise = (async () => {
3611
3765
  const response = await this.auth.login({ apiKey: this.config.apiKey });
3612
- this.http.setAuthToken(response.accessToken);
3613
- this.tokenExpiresAt = Date.now() + response.expiresIn * 1e3;
3614
- this.config.refreshToken = response.refreshToken;
3615
3766
  this.cachedJwt = response.accessToken;
3616
3767
  return response.accessToken;
3617
3768
  })();
@@ -3807,6 +3958,9 @@ var DEFAULT_OPTIONS = {
3807
3958
  autoStart: false,
3808
3959
  shutdownTimeout: 3e4
3809
3960
  };
3961
+ var JOB_TIMEOUT_MESSAGE = "Job timeout exceeded";
3962
+ var SHUTDOWN_TIMEOUT_MESSAGE = "Worker shutdown timeout";
3963
+ var MAX_TIMER_MS = 2 ** 31 - 1;
3810
3964
  var SpooledWorker = class {
3811
3965
  client;
3812
3966
  /**
@@ -3823,6 +3977,10 @@ var SpooledWorker = class {
3823
3977
  pollTimer = null;
3824
3978
  workerHeartbeatTimer = null;
3825
3979
  shutdownPromise = null;
3980
+ /** The claim request currently on the wire, so a stop can wait for its jobs. */
3981
+ pollInFlight = null;
3982
+ /** Wakes the shutdown drain when the last active job has been acked. */
3983
+ drainWaiter = null;
3826
3984
  // Event handlers
3827
3985
  eventHandlers = /* @__PURE__ */ new Map();
3828
3986
  constructor(client, options) {
@@ -3839,7 +3997,11 @@ var SpooledWorker = class {
3839
3997
  this.debug = config.debug ?? (() => {
3840
3998
  });
3841
3999
  if (this.options.autoStart) {
3842
- setImmediate(() => this.start());
4000
+ setImmediate(() => {
4001
+ this.start().catch((error) => {
4002
+ this.debug("Auto-start failed", error);
4003
+ });
4004
+ });
3843
4005
  }
3844
4006
  }
3845
4007
  /**
@@ -3910,7 +4072,15 @@ var SpooledWorker = class {
3910
4072
  }
3911
4073
  }
3912
4074
  /**
3913
- * Stop the worker gracefully
4075
+ * Stop the worker gracefully.
4076
+ *
4077
+ * Drains: the worker stops claiming, then lets running handlers finish for
4078
+ * up to `shutdownTimeout`. A handler that returns completes its job and one
4079
+ * that throws fails it, even though a stop was requested meanwhile; its
4080
+ * `signal` is NOT aborted during the grace period. Only handlers still
4081
+ * running when the timeout ends are aborted and failed with "Worker shutdown
4082
+ * timeout". The worker deregisters last, because deregistering returns every
4083
+ * job the worker still holds to the queue.
3914
4084
  */
3915
4085
  async stop() {
3916
4086
  if (this.state !== "running") {
@@ -3923,39 +4093,37 @@ var SpooledWorker = class {
3923
4093
  return this.shutdownPromise;
3924
4094
  }
3925
4095
  async doStop() {
4096
+ const deadline = Date.now() + this.options.shutdownTimeout;
3926
4097
  this.state = "stopping";
3927
4098
  this.debug("Stopping worker...");
3928
4099
  if (this.pollTimer) {
3929
4100
  clearTimeout(this.pollTimer);
3930
4101
  this.pollTimer = null;
3931
4102
  }
3932
- if (this.workerHeartbeatTimer) {
3933
- clearInterval(this.workerHeartbeatTimer);
3934
- this.workerHeartbeatTimer = null;
3935
- }
3936
- for (const active of this.activeJobs.values()) {
3937
- active.abortController.abort();
4103
+ if (this.pollInFlight) {
4104
+ await this.pollInFlight;
3938
4105
  }
3939
4106
  if (this.activeJobs.size > 0) {
3940
4107
  this.debug(
3941
4108
  `Waiting for ${this.activeJobs.size} active jobs to complete...`
3942
4109
  );
3943
- await Promise.race([
3944
- this.waitForActiveJobs(),
3945
- this.sleep(this.options.shutdownTimeout)
3946
- ]);
3947
- const remaining = Array.from(this.activeJobs.values());
3948
- for (const active of remaining) {
3949
- this.debug(
3950
- `Force-failing job ${active.job.id} due to shutdown timeout`
3951
- );
3952
- await this.failJob(active.job, "Worker shutdown timeout");
3953
- this.cleanupJob(active);
3954
- }
4110
+ await this.waitForDrain(Math.max(0, deadline - Date.now()));
4111
+ const stragglers = Array.from(this.activeJobs.values()).filter(
4112
+ (active) => !active.settled
4113
+ );
4114
+ await Promise.all(stragglers.map((active) => this.abandonJob(active)));
4115
+ await Promise.all(
4116
+ Array.from(this.activeJobs.values()).map((active) => active.ack)
4117
+ );
4118
+ }
4119
+ if (this.workerHeartbeatTimer) {
4120
+ clearInterval(this.workerHeartbeatTimer);
4121
+ this.workerHeartbeatTimer = null;
3955
4122
  }
3956
- if (this.workerId) {
4123
+ const workerId = this.workerId;
4124
+ if (workerId) {
3957
4125
  try {
3958
- await this.client.workers.deregister(this.workerId);
4126
+ await this.client.workers.deregister(workerId);
3959
4127
  this.debug("Worker deregistered");
3960
4128
  } catch (error) {
3961
4129
  this.debug("Failed to deregister worker", error);
@@ -3963,7 +4131,7 @@ var SpooledWorker = class {
3963
4131
  }
3964
4132
  this.state = "stopped";
3965
4133
  this.workerId = null;
3966
- this.emit("stopped", { workerId: this.workerId ?? "", reason: "graceful" });
4134
+ this.emit("stopped", { workerId: workerId ?? "", reason: "graceful" });
3967
4135
  this.shutdownPromise = null;
3968
4136
  }
3969
4137
  /**
@@ -4002,7 +4170,17 @@ var SpooledWorker = class {
4002
4170
  if (this.state !== "running") {
4003
4171
  return;
4004
4172
  }
4005
- this.pollTimer = setTimeout(() => this.poll(), this.options.pollInterval);
4173
+ this.pollTimer = setTimeout(() => {
4174
+ this.pollTimer = null;
4175
+ const run = this.poll();
4176
+ this.pollInFlight = run;
4177
+ const done = () => {
4178
+ if (this.pollInFlight === run) {
4179
+ this.pollInFlight = null;
4180
+ }
4181
+ };
4182
+ run.then(done, done);
4183
+ }, this.options.pollInterval);
4006
4184
  }
4007
4185
  async poll() {
4008
4186
  if (this.state !== "running" || !this.workerId) {
@@ -4036,7 +4214,8 @@ var SpooledWorker = class {
4036
4214
  executionId: Symbol(job.id),
4037
4215
  job,
4038
4216
  startedAt: /* @__PURE__ */ new Date(),
4039
- abortController
4217
+ abortController,
4218
+ settled: false
4040
4219
  };
4041
4220
  this.activeJobs.set(activeJob.executionId, activeJob);
4042
4221
  const heartbeatInterval = this.options.leaseDuration * this.options.heartbeatFraction * 1e3;
@@ -4065,23 +4244,72 @@ var SpooledWorker = class {
4065
4244
  }
4066
4245
  };
4067
4246
  try {
4068
- const result = await this.handler(context);
4069
- if (signal.aborted) {
4070
- this.debug(`Job ${job.id} was aborted`);
4247
+ const outcome = await this.runHandler(activeJob, context);
4248
+ if (activeJob.settled) {
4249
+ this.debug(`Job ${job.id} was already settled; dropping late result`);
4071
4250
  return;
4072
4251
  }
4073
- await this.completeJob(job, result ?? void 0);
4074
- } catch (error) {
4075
- if (signal.aborted) {
4076
- this.debug(`Job ${job.id} was aborted`);
4077
- return;
4252
+ activeJob.settled = true;
4253
+ switch (outcome.kind) {
4254
+ case "returned":
4255
+ activeJob.ack = this.completeJob(job, outcome.value ?? void 0);
4256
+ break;
4257
+ case "threw":
4258
+ activeJob.ack = this.failJob(
4259
+ job,
4260
+ outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
4261
+ );
4262
+ break;
4263
+ case "timeout":
4264
+ abortController.abort(new Error(JOB_TIMEOUT_MESSAGE));
4265
+ this.emit("job:timeout", {
4266
+ jobId: job.id,
4267
+ queueName: job.queueName
4268
+ });
4269
+ activeJob.ack = this.failJob(job, JOB_TIMEOUT_MESSAGE);
4270
+ break;
4078
4271
  }
4079
- const errorMessage = error instanceof Error ? error.message : String(error);
4080
- await this.failJob(job, errorMessage);
4272
+ await activeJob.ack;
4081
4273
  } finally {
4082
4274
  this.cleanupJob(activeJob);
4083
4275
  }
4084
4276
  }
4277
+ /**
4278
+ * Run the handler, racing it against the job's `timeoutSeconds`.
4279
+ * Never rejects: a throw (sync or async) is reported as an outcome.
4280
+ */
4281
+ runHandler(activeJob, context) {
4282
+ const run = (async () => {
4283
+ try {
4284
+ return { kind: "returned", value: await this.handler(context) };
4285
+ } catch (error) {
4286
+ return { kind: "threw", error };
4287
+ }
4288
+ })();
4289
+ const { timeoutSeconds } = activeJob.job;
4290
+ if (typeof timeoutSeconds !== "number" || !(timeoutSeconds > 0)) {
4291
+ return run;
4292
+ }
4293
+ return new Promise((resolve) => {
4294
+ activeJob.timeoutTimer = setTimeout(
4295
+ () => resolve({ kind: "timeout" }),
4296
+ Math.min(timeoutSeconds * 1e3, MAX_TIMER_MS)
4297
+ );
4298
+ run.then((outcome) => {
4299
+ this.clearTimeoutTimer(activeJob);
4300
+ resolve(outcome);
4301
+ });
4302
+ });
4303
+ }
4304
+ /** Abort a handler that outlived the shutdown grace period and fail its job. */
4305
+ async abandonJob(activeJob) {
4306
+ this.debug(`Force-failing job ${activeJob.job.id} due to shutdown timeout`);
4307
+ activeJob.settled = true;
4308
+ activeJob.abortController.abort(new Error(SHUTDOWN_TIMEOUT_MESSAGE));
4309
+ activeJob.ack = this.failJob(activeJob.job, SHUTDOWN_TIMEOUT_MESSAGE);
4310
+ await activeJob.ack;
4311
+ this.cleanupJob(activeJob);
4312
+ }
4085
4313
  async completeJob(job, result) {
4086
4314
  if (!this.workerId) return;
4087
4315
  try {
@@ -4097,25 +4325,34 @@ var SpooledWorker = class {
4097
4325
  });
4098
4326
  } catch (error) {
4099
4327
  this.debug(`Failed to complete job ${job.id}`, error);
4328
+ this.emit("error", { error });
4100
4329
  }
4101
4330
  }
4102
4331
  async failJob(job, errorMessage) {
4103
4332
  if (!this.workerId) return;
4104
4333
  const willRetry = job.retryCount < job.maxRetries;
4334
+ const message = coerceFailureMessage(errorMessage);
4105
4335
  try {
4106
4336
  await this.client.jobs.fail(job.id, {
4107
4337
  workerId: this.workerId,
4108
- error: errorMessage,
4338
+ error: message,
4109
4339
  ...job.leaseId != null && { leaseId: job.leaseId }
4110
4340
  });
4111
4341
  this.emit("job:failed", {
4112
4342
  jobId: job.id,
4113
4343
  queueName: job.queueName,
4114
- error: errorMessage,
4344
+ error: message,
4115
4345
  willRetry
4116
4346
  });
4117
4347
  } catch (error) {
4118
4348
  this.debug(`Failed to fail job ${job.id}`, error);
4349
+ this.emit("error", { error });
4350
+ }
4351
+ }
4352
+ clearTimeoutTimer(activeJob) {
4353
+ if (activeJob.timeoutTimer) {
4354
+ clearTimeout(activeJob.timeoutTimer);
4355
+ activeJob.timeoutTimer = void 0;
4119
4356
  }
4120
4357
  }
4121
4358
  cleanupJob(activeJob) {
@@ -4123,8 +4360,12 @@ var SpooledWorker = class {
4123
4360
  clearInterval(activeJob.heartbeatTimer);
4124
4361
  activeJob.heartbeatTimer = void 0;
4125
4362
  }
4363
+ this.clearTimeoutTimer(activeJob);
4126
4364
  if (this.activeJobs.get(activeJob.executionId) === activeJob) {
4127
4365
  this.activeJobs.delete(activeJob.executionId);
4366
+ if (this.activeJobs.size === 0) {
4367
+ this.drainWaiter?.();
4368
+ }
4128
4369
  }
4129
4370
  }
4130
4371
  async sendJobHeartbeat(activeJob) {
@@ -4141,7 +4382,9 @@ var SpooledWorker = class {
4141
4382
  }
4142
4383
  }
4143
4384
  async sendWorkerHeartbeat() {
4144
- if (!this.workerId || this.state !== "running") return;
4385
+ if (!this.workerId || this.state !== "running" && this.state !== "stopping") {
4386
+ return;
4387
+ }
4145
4388
  try {
4146
4389
  await this.client.workers.heartbeat(this.workerId, {
4147
4390
  currentJobs: this.activeJobs.size,
@@ -4151,13 +4394,25 @@ var SpooledWorker = class {
4151
4394
  this.debug("Worker heartbeat failed", error);
4152
4395
  }
4153
4396
  }
4154
- async waitForActiveJobs() {
4155
- while (this.activeJobs.size > 0) {
4156
- await this.sleep(100);
4397
+ /**
4398
+ * Resolve once no job is active, or after `timeoutMs`. The timer is always
4399
+ * cleared, so a clean stop leaves nothing that keeps the process alive.
4400
+ */
4401
+ waitForDrain(timeoutMs) {
4402
+ if (this.activeJobs.size === 0) {
4403
+ return Promise.resolve();
4157
4404
  }
4158
- }
4159
- sleep(ms) {
4160
- return new Promise((resolve) => setTimeout(resolve, ms));
4405
+ return new Promise((resolve) => {
4406
+ const timer = setTimeout(() => {
4407
+ this.drainWaiter = null;
4408
+ resolve();
4409
+ }, timeoutMs);
4410
+ this.drainWaiter = () => {
4411
+ clearTimeout(timer);
4412
+ this.drainWaiter = null;
4413
+ resolve();
4414
+ };
4415
+ });
4161
4416
  }
4162
4417
  };
4163
4418
 
@@ -4199,6 +4454,7 @@ exports.SpooledRealtime = SpooledRealtime;
4199
4454
  exports.SpooledWorker = SpooledWorker;
4200
4455
  exports.SseRealtimeClient = SseRealtimeClient;
4201
4456
  exports.TimeoutError = TimeoutError;
4457
+ exports.UnsupportedOperationError = UnsupportedOperationError;
4202
4458
  exports.ValidationError = ValidationError;
4203
4459
  exports.WebSocketRealtimeClient = WebSocketRealtimeClient;
4204
4460
  exports.WebhookIngestionResource = WebhookIngestionResource;