@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.js CHANGED
@@ -1,5 +1,3 @@
1
- import { createHmac } from 'crypto';
2
- import { Buffer } from 'buffer';
3
1
  import * as grpc2 from '@grpc/grpc-js';
4
2
  import * as protoLoader from '@grpc/proto-loader';
5
3
  import { existsSync } from 'fs';
@@ -26,10 +24,10 @@ var DEFAULT_CONFIG = {
26
24
  successThreshold: 3,
27
25
  timeout: 3e4
28
26
  },
29
- userAgent: "@spooled/sdk-nodejs/1.2.0",
27
+ userAgent: "@spooled/sdk-nodejs/1.3.0",
30
28
  autoRefreshToken: true
31
29
  };
32
- var SDK_VERSION = "1.2.0";
30
+ var SDK_VERSION = "1.3.0";
33
31
  var API_VERSION = "v1";
34
32
  var API_BASE_PATH = `/api/${API_VERSION}`;
35
33
  function normalizeCredential(value) {
@@ -86,9 +84,9 @@ function resolveConfig(options) {
86
84
  };
87
85
  }
88
86
  function validateConfig(config) {
89
- if (!config.apiKey && !config.accessToken) {
87
+ if (!config.apiKey && !config.accessToken && !config.adminKey) {
90
88
  throw new Error(
91
- "SpooledClient requires either apiKey or accessToken for authentication"
89
+ "SpooledClient requires either apiKey or accessToken for authentication (or adminKey for admin-only use)"
92
90
  );
93
91
  }
94
92
  if (config.apiKey && !config.apiKey.startsWith("sp_") && !config.apiKey.startsWith("sk_")) {
@@ -151,9 +149,15 @@ var SpooledError = class extends Error {
151
149
  Error.captureStackTrace(this, this.constructor);
152
150
  }
153
151
  }
154
- /** Whether this error is retryable */
152
+ /**
153
+ * Whether this error is retryable.
154
+ *
155
+ * Only 408 (Request Timeout) is retryable among the plain 4xx statuses: the
156
+ * server gave up waiting for the request, so it never ran. Subclasses
157
+ * override this (5xx, network, timeout, rate limit).
158
+ */
155
159
  isRetryable() {
156
- return false;
160
+ return this.statusCode === 408;
157
161
  }
158
162
  /** Convert to plain object for logging */
159
163
  toJSON() {
@@ -192,8 +196,8 @@ var ConflictError = class extends SpooledError {
192
196
  }
193
197
  };
194
198
  var ValidationError = class extends SpooledError {
195
- constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId) {
196
- super(message, 400, code, details, requestId);
199
+ constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId, statusCode = 400) {
200
+ super(message, statusCode, code, details, requestId);
197
201
  this.name = "ValidationError";
198
202
  }
199
203
  };
@@ -203,6 +207,7 @@ var PayloadTooLargeError = class extends SpooledError {
203
207
  this.name = "PayloadTooLargeError";
204
208
  }
205
209
  };
210
+ var QUOTA_MESSAGE = /\blimit reached\b/i;
206
211
  var RateLimitError = class extends SpooledError {
207
212
  /** Rate limit information */
208
213
  rateLimitInfo;
@@ -211,8 +216,27 @@ var RateLimitError = class extends SpooledError {
211
216
  this.name = "RateLimitError";
212
217
  this.rateLimitInfo = rateLimitInfo;
213
218
  }
219
+ /**
220
+ * True when this 429 is a plan quota or limit (daily job cap, active jobs,
221
+ * queue/worker/webhook caps) rather than per-second rate limiting.
222
+ *
223
+ * A quota does not clear for minutes or hours, so retrying it only blocks the
224
+ * caller for the whole backoff and, via the circuit breaker, takes every
225
+ * other call on the client down with it. It is never retried and never
226
+ * counted as a circuit breaker failure.
227
+ *
228
+ * Detection: the `QUOTA_EXCEEDED` code, or, for older plan-limit paths that
229
+ * answer with plain text wrapped as `RATE_LIMIT_EXCEEDED`, a "... limit
230
+ * reached" message that carries no `Retry-After`.
231
+ */
232
+ get isQuota() {
233
+ if (this.code === "QUOTA_EXCEEDED") {
234
+ return true;
235
+ }
236
+ return this.rateLimitInfo.retryAfter === void 0 && QUOTA_MESSAGE.test(this.message);
237
+ }
214
238
  isRetryable() {
215
- return true;
239
+ return !this.isQuota;
216
240
  }
217
241
  /** Get the number of seconds to wait before retrying */
218
242
  getRetryAfter() {
@@ -272,6 +296,20 @@ var CircuitBreakerOpenError = class extends SpooledError {
272
296
  return false;
273
297
  }
274
298
  };
299
+ var UnsupportedOperationError = class extends SpooledError {
300
+ /** The SDK method that is not supported, e.g. `ingest.github` */
301
+ operation;
302
+ constructor(operation, reason) {
303
+ super(
304
+ `${operation} is not supported by the Spooled API: ${reason}`,
305
+ 0,
306
+ "UNSUPPORTED_OPERATION",
307
+ { operation }
308
+ );
309
+ this.name = "UnsupportedOperationError";
310
+ this.operation = operation;
311
+ }
312
+ };
275
313
  function parseRateLimitHeaders(headers) {
276
314
  const info = {};
277
315
  const retryAfter = headers.get("Retry-After");
@@ -343,6 +381,10 @@ async function createErrorFromResponse(response) {
343
381
  switch (response.status) {
344
382
  case 400:
345
383
  return new ValidationError(message, code, details, requestId);
384
+ // A missing or mistyped JSON field is a 422 from the API's extractor; it is
385
+ // the same class of caller error as a 400 field-rule failure.
386
+ case 422:
387
+ return new ValidationError(message, code, details, requestId, 422);
346
388
  case 401:
347
389
  return new AuthenticationError(message, code, details, requestId);
348
390
  case 403:
@@ -571,8 +613,12 @@ var HttpClient = class {
571
613
  authToken;
572
614
  /** Optional function to mint a fresh access token (set when auto-refresh is enabled) */
573
615
  refreshTokenFn = null;
574
- /** Guard to prevent a refresh request from recursively triggering another refresh */
575
- isRefreshing = false;
616
+ /**
617
+ * The refresh currently in flight, shared by every request that gets a 401
618
+ * meanwhile. A boolean guard here made the second concurrent 401 skip the
619
+ * refresh and fail even though the token was about to be replaced.
620
+ */
621
+ refreshInFlight = null;
576
622
  constructor(config, circuitBreaker) {
577
623
  this.config = config;
578
624
  this.circuitBreaker = circuitBreaker;
@@ -594,6 +640,30 @@ var HttpClient = class {
594
640
  setRefreshTokenFn(fn) {
595
641
  this.refreshTokenFn = fn;
596
642
  }
643
+ /**
644
+ * Refresh the access token once for all callers.
645
+ *
646
+ * Resolves true when a new token is installed, false when the refresh failed
647
+ * (the caller then surfaces its original 401).
648
+ */
649
+ refreshAuthToken() {
650
+ if (!this.refreshInFlight) {
651
+ const refresh = this.refreshTokenFn;
652
+ this.refreshInFlight = (async () => {
653
+ try {
654
+ this.setAuthToken(await refresh());
655
+ return true;
656
+ } catch (refreshError) {
657
+ const message = refreshError instanceof Error ? refreshError.message : "unknown";
658
+ this.debug?.("Token refresh failed", { error: message });
659
+ return false;
660
+ } finally {
661
+ this.refreshInFlight = null;
662
+ }
663
+ })();
664
+ }
665
+ return this.refreshInFlight;
666
+ }
597
667
  /**
598
668
  * Build full URL with query parameters
599
669
  */
@@ -669,6 +739,7 @@ var HttpClient = class {
669
739
  */
670
740
  async executeRequest(url, options, hasRefreshed = false) {
671
741
  const method = options.method ?? "GET";
742
+ const tokenUsed = this.authToken;
672
743
  const headers = this.buildHeaders(options.headers);
673
744
  const timeout = options.timeout ?? this.config.timeout;
674
745
  const init = {
@@ -683,6 +754,9 @@ var HttpClient = class {
683
754
  init.body = JSON.stringify(body);
684
755
  }
685
756
  }
757
+ if (init.body === void 0) {
758
+ headers.delete("Content-Type");
759
+ }
686
760
  this.debug?.(`${method} ${url}`, { timeout, hasBody: !!options.body });
687
761
  let response;
688
762
  try {
@@ -704,20 +778,9 @@ var HttpClient = class {
704
778
  }
705
779
  this.debug?.(`Response: ${response.status}`, { url });
706
780
  if (!response.ok) {
707
- if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !this.isRefreshing) {
708
- let refreshed = false;
709
- this.isRefreshing = true;
710
- try {
711
- const newToken = await this.refreshTokenFn();
712
- this.setAuthToken(newToken);
713
- refreshed = true;
714
- } catch (refreshError) {
715
- const message = refreshError instanceof Error ? refreshError.message : "unknown";
716
- this.debug?.("Token refresh failed", { error: message });
717
- } finally {
718
- this.isRefreshing = false;
719
- }
720
- if (refreshed) {
781
+ if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !options.skipAuthRefresh) {
782
+ const alreadyRefreshed = this.authToken !== tokenUsed;
783
+ if (alreadyRefreshed || await this.refreshAuthToken()) {
721
784
  return this.executeRequest(url, options, true);
722
785
  }
723
786
  }
@@ -779,22 +842,26 @@ var HttpClient = class {
779
842
  /**
780
843
  * Resolve the retry configuration for a request.
781
844
  *
782
- * Idempotent methods (GET/PUT/DELETE) and any request carrying an
783
- * idempotency key retry normally. Non-idempotent methods (POST/PATCH)
784
- * without an idempotency key must NOT be retried on ambiguous failures
845
+ * Idempotent methods (GET/PUT/DELETE), any request carrying an idempotency
846
+ * key and any request marked `idempotent` retry normally. Non-idempotent
847
+ * methods (POST/PATCH) otherwise must NOT be retried on ambiguous failures
785
848
  * (network/timeout/5xx) — doing so risks duplicate jobs, double claims,
786
- * etc. They are still retried on 429, which is rejected before the
787
- * server processes the request and is therefore side-effect-free.
849
+ * etc. They are still retried on a rate-limit 429, which is rejected before
850
+ * the server processes the request and is therefore side-effect-free. A
851
+ * quota 429 is never retried: it does not clear for hours.
788
852
  */
789
853
  resolveRetryConfig(method, options) {
790
854
  const base = this.config.retry;
791
- if (IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
855
+ if (options.idempotent || IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
792
856
  return base;
793
857
  }
794
858
  return {
795
859
  ...base,
796
860
  retryOn: (error, attempt) => {
797
861
  if (error instanceof RateLimitError) {
862
+ if (error.isQuota) {
863
+ return false;
864
+ }
798
865
  return base.retryOn ? base.retryOn(error, attempt) : true;
799
866
  }
800
867
  return false;
@@ -1025,13 +1092,17 @@ var AuthResource = class {
1025
1092
  * Exchange API key for JWT tokens
1026
1093
  */
1027
1094
  async login(params) {
1028
- return this.http.post("/auth/login", params);
1095
+ return this.http.post("/auth/login", params, {
1096
+ skipAuthRefresh: true
1097
+ });
1029
1098
  }
1030
1099
  /**
1031
1100
  * Refresh access token using refresh token
1032
1101
  */
1033
1102
  async refresh(params) {
1034
- return this.http.post("/auth/refresh", params);
1103
+ return this.http.post("/auth/refresh", params, {
1104
+ skipAuthRefresh: true
1105
+ });
1035
1106
  }
1036
1107
  /**
1037
1108
  * Logout and revoke the refresh token server-side.
@@ -1102,6 +1173,21 @@ function mapValidate(raw) {
1102
1173
  };
1103
1174
  }
1104
1175
 
1176
+ // src/utils/failure.ts
1177
+ var MAX_FAILURE_MESSAGE_CHARS = 2048;
1178
+ var DEFAULT_FAILURE_MESSAGE = "Job failed";
1179
+ function coerceFailureMessage(message) {
1180
+ const text = typeof message === "string" ? message : message == null ? "" : `${message}`;
1181
+ if (text === "") {
1182
+ return DEFAULT_FAILURE_MESSAGE;
1183
+ }
1184
+ if (text.length <= MAX_FAILURE_MESSAGE_CHARS) {
1185
+ return text;
1186
+ }
1187
+ const codePoints = Array.from(text);
1188
+ return codePoints.length <= MAX_FAILURE_MESSAGE_CHARS ? text : codePoints.slice(0, MAX_FAILURE_MESSAGE_CHARS).join("");
1189
+ }
1190
+
1105
1191
  // src/resources/jobs.ts
1106
1192
  var JobsResource = class {
1107
1193
  constructor(http) {
@@ -1118,7 +1204,9 @@ var JobsResource = class {
1118
1204
  * Create a new job
1119
1205
  */
1120
1206
  async create(params) {
1121
- return this.http.post("/jobs", params);
1207
+ return this.http.post("/jobs", params, {
1208
+ idempotent: params.idempotencyKey ? true : void 0
1209
+ });
1122
1210
  }
1123
1211
  /**
1124
1212
  * Create a job and return the full job object
@@ -1202,26 +1290,43 @@ var JobsResource = class {
1202
1290
  }
1203
1291
  /**
1204
1292
  * Complete a job (worker ack)
1293
+ *
1294
+ * Lease-fenced and idempotent on the server, so a network error, timeout or
1295
+ * 5xx is retried: a lost ack would otherwise re-run a finished job.
1205
1296
  */
1206
1297
  async complete(id, params) {
1207
- return this.http.post(`/jobs/${id}/complete`, params);
1298
+ return this.http.post(
1299
+ `/jobs/${id}/complete`,
1300
+ params,
1301
+ { idempotent: true }
1302
+ );
1208
1303
  }
1209
1304
  /**
1210
1305
  * Fail a job (worker nack)
1306
+ *
1307
+ * `params.error` is made acceptable first: empty text becomes "Job failed"
1308
+ * and text over 2048 characters is truncated, because the API rejects both
1309
+ * with a 400 that would leave the job processing until its lease expires.
1310
+ * Retried like {@link JobsResource.complete}.
1211
1311
  */
1212
1312
  async fail(id, params) {
1213
1313
  return this.http.post(
1214
1314
  `/jobs/${id}/fail`,
1215
- params
1315
+ { ...params, error: coerceFailureMessage(params.error) },
1316
+ { idempotent: true }
1216
1317
  );
1217
1318
  }
1218
1319
  /**
1219
1320
  * Extend job lease (heartbeat)
1321
+ *
1322
+ * Retried like {@link JobsResource.complete}: with the default 30 s lease and
1323
+ * 15 s heartbeat, one lost heartbeat leaves no slack.
1220
1324
  */
1221
1325
  async heartbeat(id, params) {
1222
1326
  return this.http.post(
1223
1327
  `/jobs/${id}/heartbeat`,
1224
- params
1328
+ params,
1329
+ { idempotent: true }
1225
1330
  );
1226
1331
  }
1227
1332
  // DLQ operations (private implementations)
@@ -1317,9 +1422,17 @@ var QueuesResource = class {
1317
1422
  }
1318
1423
  /**
1319
1424
  * Delete a queue configuration
1320
- */
1321
- async delete(name) {
1322
- await this.http.delete(`/queues/${encodeURIComponent(name)}`);
1425
+ *
1426
+ * Without `deleteJobs` the API only deletes an explicit queue config of an
1427
+ * idle queue, and answers 409 otherwise: a queue that exists only because
1428
+ * jobs were posted to it has no config to delete, and a queue with pending
1429
+ * or processing jobs is not idle. Pass `{ deleteJobs: true }` to delete the
1430
+ * queue's jobs (and DLQ rows) along with it.
1431
+ */
1432
+ async delete(name, options) {
1433
+ await this.http.delete(`/queues/${encodeURIComponent(name)}`, {
1434
+ params: options?.deleteJobs ? { deleteJobs: true } : void 0
1435
+ });
1323
1436
  }
1324
1437
  };
1325
1438
 
@@ -1348,9 +1461,13 @@ var WorkersResource = class {
1348
1461
  }
1349
1462
  /**
1350
1463
  * Send worker heartbeat
1464
+ *
1465
+ * Idempotent on the server, so network errors, timeouts and 5xx are retried.
1351
1466
  */
1352
1467
  async heartbeat(id, params) {
1353
- await this.http.post(`/workers/${id}/heartbeat`, params);
1468
+ await this.http.post(`/workers/${id}/heartbeat`, params, {
1469
+ idempotent: true
1470
+ });
1354
1471
  }
1355
1472
  /**
1356
1473
  * Deregister a worker
@@ -1499,7 +1616,7 @@ var WorkflowsResource = class {
1499
1616
  const jobs = await this.listWorkflowJobs(workflowId);
1500
1617
  return jobs.map((job) => ({
1501
1618
  jobId: job.id,
1502
- key: job.key,
1619
+ ...job.key !== void 0 && { key: job.key },
1503
1620
  status: job.status
1504
1621
  }));
1505
1622
  }
@@ -1557,7 +1674,10 @@ function mapWorkflowDetailJobs(detail) {
1557
1674
  return {
1558
1675
  id,
1559
1676
  workflowId: String(job.workflowId ?? workflowId),
1560
- key: String(job.key ?? ""),
1677
+ // The API does not return the creation-time key (jobs store only a
1678
+ // workflow step), so there is nothing to put here unless a future
1679
+ // version sends one. Never fabricate "".
1680
+ ...typeof job.key === "string" && job.key !== "" && { key: job.key },
1561
1681
  queueName: String(job.queueName ?? job.queue ?? ""),
1562
1682
  status: job.status,
1563
1683
  payload: job.payload ?? {},
@@ -1769,19 +1889,18 @@ var OrganizationsResource = class {
1769
1889
  /**
1770
1890
  * Clear/delete the webhook token for the current organization.
1771
1891
  *
1772
- * POST /organizations/webhook-token/clear requires `{ confirm: true }`.
1773
- * An empty body 422s. The API currently rejects the operation and tells
1774
- * callers to use regenerateWebhookToken() instead.
1775
- *
1776
- * @example
1777
- * ```typescript
1778
- * await client.organizations.clearWebhookToken();
1779
- * ```
1892
+ * @deprecated The API refuses this operation on purpose (an org without a
1893
+ * webhook token would have an open public ingestion endpoint) and always
1894
+ * answers with an error. This method now throws
1895
+ * {@link UnsupportedOperationError} without a network call; rotate the token
1896
+ * with {@link OrganizationsResource.regenerateWebhookToken} instead. It will
1897
+ * be removed in the next major release.
1780
1898
  */
1781
1899
  async clearWebhookToken() {
1782
- await this.http.post("/organizations/webhook-token/clear", {
1783
- confirm: true
1784
- });
1900
+ throw new UnsupportedOperationError(
1901
+ "organizations.clearWebhookToken",
1902
+ "clearing the webhook token is not allowed; use organizations.regenerateWebhookToken() to rotate it"
1903
+ );
1785
1904
  }
1786
1905
  };
1787
1906
 
@@ -1989,24 +2108,8 @@ var AdminResource = class {
1989
2108
  });
1990
2109
  }
1991
2110
  };
1992
- function toBytes(body) {
1993
- if (typeof body === "string") {
1994
- return Buffer.from(body, "utf8");
1995
- }
1996
- if (body instanceof Uint8Array) {
1997
- return body;
1998
- }
1999
- return new Uint8Array(body);
2000
- }
2001
- function githubSignature(secret, body) {
2002
- const hex = createHmac("sha256", secret).update(body).digest("hex");
2003
- return `sha256=${hex}`;
2004
- }
2005
- function stripeSignature(secret, body, timestamp) {
2006
- const payload = `${timestamp}.${Buffer.from(body).toString("utf8")}`;
2007
- const hex = createHmac("sha256", secret).update(payload, "utf8").digest("hex");
2008
- return `t=${timestamp},v1=${hex}`;
2009
- }
2111
+
2112
+ // src/resources/webhook-ingestion.ts
2010
2113
  var WebhookIngestionResource = class {
2011
2114
  constructor(http) {
2012
2115
  this.http = http;
@@ -2014,66 +2117,32 @@ var WebhookIngestionResource = class {
2014
2117
  /**
2015
2118
  * Ingest a GitHub webhook.
2016
2119
  *
2017
- * POST /api/v1/webhooks/{org_id}/github
2018
- */
2019
- async github(orgId, body, options) {
2020
- const bytes = toBytes(body);
2021
- const signature = options.signature ?? (options.secret ? githubSignature(options.secret, bytes) : void 0);
2022
- if (!signature) {
2023
- throw new ValidationError(
2024
- "GitHub webhook signature is required (provide `signature` or `secret`)"
2025
- );
2026
- }
2027
- const headers = {
2028
- "Content-Type": "application/json",
2029
- "X-GitHub-Event": options.githubEvent,
2030
- "X-Hub-Signature-256": signature
2031
- };
2032
- if (options.webhookToken) {
2033
- headers["X-Webhook-Token"] = options.webhookToken;
2034
- }
2035
- if (options.forwardedProto) {
2036
- headers["X-Forwarded-Proto"] = options.forwardedProto;
2037
- }
2038
- await this.http.request(`/webhooks/${orgId}/github`, {
2039
- method: "POST",
2040
- rawBody: bytes,
2041
- headers,
2042
- skipRequestConversion: true,
2043
- skipResponseConversion: true
2044
- });
2120
+ * @deprecated The API has no `/webhooks/{org_id}/github` route (it always
2121
+ * answered 404). This method now throws {@link UnsupportedOperationError}
2122
+ * without a network call; point the GitHub webhook at `ingest.custom` /
2123
+ * `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
2124
+ * next major release.
2125
+ */
2126
+ async github(_orgId, _body, _options) {
2127
+ throw new UnsupportedOperationError(
2128
+ "ingest.github",
2129
+ "the API has no GitHub webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
2130
+ );
2045
2131
  }
2046
2132
  /**
2047
2133
  * Ingest a Stripe webhook.
2048
2134
  *
2049
- * POST /api/v1/webhooks/{org_id}/stripe
2050
- */
2051
- async stripe(orgId, body, options = {}) {
2052
- const bytes = toBytes(body);
2053
- const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
2054
- const signature = options.signature ?? (options.secret ? stripeSignature(options.secret, bytes, timestamp) : void 0);
2055
- if (!signature) {
2056
- throw new ValidationError(
2057
- "Stripe webhook signature is required (provide `signature` or `secret`)"
2058
- );
2059
- }
2060
- const headers = {
2061
- "Content-Type": "application/json",
2062
- "Stripe-Signature": signature
2063
- };
2064
- if (options.webhookToken) {
2065
- headers["X-Webhook-Token"] = options.webhookToken;
2066
- }
2067
- if (options.forwardedProto) {
2068
- headers["X-Forwarded-Proto"] = options.forwardedProto;
2069
- }
2070
- await this.http.request(`/webhooks/${orgId}/stripe`, {
2071
- method: "POST",
2072
- rawBody: bytes,
2073
- headers,
2074
- skipRequestConversion: true,
2075
- skipResponseConversion: true
2076
- });
2135
+ * @deprecated The API has no `/webhooks/{org_id}/stripe` route (it always
2136
+ * answered 404). This method now throws {@link UnsupportedOperationError}
2137
+ * without a network call; point the Stripe webhook at `ingest.custom` /
2138
+ * `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
2139
+ * next major release.
2140
+ */
2141
+ async stripe(_orgId, _body, _options = {}) {
2142
+ throw new UnsupportedOperationError(
2143
+ "ingest.stripe",
2144
+ "the API has no Stripe webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
2145
+ );
2077
2146
  }
2078
2147
  /**
2079
2148
  * Ingest a custom webhook.
@@ -2437,6 +2506,26 @@ function isAuthFailureEvent(event) {
2437
2506
  }
2438
2507
 
2439
2508
  // src/realtime/sse.ts
2509
+ var SSE_EVENT_NAMES = [
2510
+ "job.created",
2511
+ "job.status",
2512
+ "job.completed",
2513
+ "job.failed",
2514
+ "queue.stats",
2515
+ "worker.heartbeat",
2516
+ "worker.registered",
2517
+ "worker.deregistered",
2518
+ "system.health",
2519
+ "ping",
2520
+ "error"
2521
+ ];
2522
+ var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
2523
+ "completed",
2524
+ "failed",
2525
+ "deadletter",
2526
+ "cancelled"
2527
+ ]);
2528
+ var STABLE_CONNECTION_MS = 3e4;
2440
2529
  var SseRealtimeClient = class {
2441
2530
  options;
2442
2531
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2445,6 +2534,12 @@ var SseRealtimeClient = class {
2445
2534
  reconnectAttempts = 0;
2446
2535
  reconnectTimer = null;
2447
2536
  filter = null;
2537
+ /** When the current connection opened (for the stable-connection check). */
2538
+ connectedAt = null;
2539
+ /** The server finished this stream on purpose (terminal job); do not reconnect. */
2540
+ streamEnded = false;
2541
+ /** The server rejected our token; mint a fresh one for the next connect. */
2542
+ forceTokenRefresh = false;
2448
2543
  // Event handlers
2449
2544
  eventHandlers = /* @__PURE__ */ new Map();
2450
2545
  allEventsHandlers = /* @__PURE__ */ new Set();
@@ -2483,6 +2578,7 @@ var SseRealtimeClient = class {
2483
2578
  return;
2484
2579
  }
2485
2580
  this.filter = filter || null;
2581
+ this.streamEnded = false;
2486
2582
  this.setState("connecting");
2487
2583
  const sseUrl = this.buildSseUrl(filter);
2488
2584
  this.options.debug(`Connecting to SSE: ${sseUrl}`);
@@ -2502,7 +2598,9 @@ var SseRealtimeClient = class {
2502
2598
  }
2503
2599
  let token;
2504
2600
  try {
2505
- token = await this.options.tokenProvider(this.reconnectAttempts > 0);
2601
+ const force = this.forceTokenRefresh;
2602
+ this.forceTokenRefresh = false;
2603
+ token = await this.options.tokenProvider(force);
2506
2604
  } catch (error) {
2507
2605
  this.setState("disconnected");
2508
2606
  throw new Error(`Failed to acquire realtime token: ${error}`);
@@ -2522,14 +2620,22 @@ var SseRealtimeClient = class {
2522
2620
  this.eventSource.onopen = () => {
2523
2621
  this.options.debug("SSE connected");
2524
2622
  this.setState("connected");
2525
- this.reconnectAttempts = 0;
2623
+ this.connectedAt = Date.now();
2526
2624
  resolve();
2527
2625
  };
2528
2626
  this.eventSource.onmessage = (event) => {
2529
2627
  this.handleMessage(event.data);
2530
2628
  };
2531
- this.eventSource.onerror = () => {
2629
+ for (const name of SSE_EVENT_NAMES) {
2630
+ this.eventSource.addEventListener(name, (event) => {
2631
+ if (typeof event?.data === "string") this.handleMessage(event.data);
2632
+ });
2633
+ }
2634
+ this.eventSource.onerror = (event) => {
2532
2635
  this.options.debug("SSE error");
2636
+ if (event?.code === 401 || event?.code === 403) {
2637
+ this.forceTokenRefresh = true;
2638
+ }
2533
2639
  if (this.state === "connecting") {
2534
2640
  this.setState("disconnected");
2535
2641
  if (this.eventSource) {
@@ -2561,6 +2667,8 @@ var SseRealtimeClient = class {
2561
2667
  }
2562
2668
  this.setState("disconnected");
2563
2669
  this.filter = null;
2670
+ this.connectedAt = null;
2671
+ this.streamEnded = false;
2564
2672
  }
2565
2673
  /**
2566
2674
  * Reconnect with a new filter (SSE requires reconnection to change subscriptions)
@@ -2608,7 +2716,7 @@ var SseRealtimeClient = class {
2608
2716
  return `${this.options.baseUrl}/api/v1/events/jobs/${encodeURIComponent(filter.jobId)}`;
2609
2717
  }
2610
2718
  if (filter?.queueName) {
2611
- return `${this.options.baseUrl}/api/v1/events/queues/${encodeURIComponent(filter.queueName)}`;
2719
+ return `${this.options.baseUrl}/api/v1/events?queue=${encodeURIComponent(filter.queueName)}`;
2612
2720
  }
2613
2721
  return `${this.options.baseUrl}/api/v1/events`;
2614
2722
  }
@@ -2625,6 +2733,9 @@ var SseRealtimeClient = class {
2625
2733
  parsed.type = eventType;
2626
2734
  const event = parsed;
2627
2735
  this.options.debug(`Received SSE event: ${eventType}`, event);
2736
+ if (this.filter?.jobId && isStreamEndingEvent(eventType, parsed.data)) {
2737
+ this.streamEnded = true;
2738
+ }
2628
2739
  const handlers = this.eventHandlers.get(eventType);
2629
2740
  if (handlers) {
2630
2741
  const eventData = event.data;
@@ -2652,6 +2763,15 @@ var SseRealtimeClient = class {
2652
2763
  this.eventSource.close();
2653
2764
  this.eventSource = null;
2654
2765
  }
2766
+ if (this.streamEnded) {
2767
+ this.connectedAt = null;
2768
+ this.setState("disconnected");
2769
+ return;
2770
+ }
2771
+ if (this.connectedAt !== null && Date.now() - this.connectedAt >= STABLE_CONNECTION_MS) {
2772
+ this.reconnectAttempts = 0;
2773
+ }
2774
+ this.connectedAt = null;
2655
2775
  if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
2656
2776
  this.scheduleReconnect();
2657
2777
  } else {
@@ -2683,6 +2803,19 @@ var SseRealtimeClient = class {
2683
2803
  }, delay);
2684
2804
  }
2685
2805
  };
2806
+ function isStreamEndingEvent(eventType, data) {
2807
+ if (eventType === "error" || eventType === "job.completed") {
2808
+ return true;
2809
+ }
2810
+ const record = data ?? {};
2811
+ if (eventType === "job.failed") {
2812
+ return record.willRetry === false;
2813
+ }
2814
+ if (eventType === "job.status_changed" || eventType === "job.status") {
2815
+ return typeof record.newStatus === "string" && TERMINAL_JOB_STATUSES.has(record.newStatus);
2816
+ }
2817
+ return false;
2818
+ }
2686
2819
 
2687
2820
  // src/realtime/index.ts
2688
2821
  var SpooledRealtime = class {
@@ -2869,6 +3002,11 @@ function clearProtoCache() {
2869
3002
  cachedDefinition = null;
2870
3003
  }
2871
3004
 
3005
+ // src/grpc/encode.ts
3006
+ function encodeFailRequest(params) {
3007
+ return { ...params, retry: params.retry ?? true };
3008
+ }
3009
+
2872
3010
  // src/grpc/streaming.ts
2873
3011
  async function* asyncIterableFromStream(stream, options) {
2874
3012
  const { signal, onConnected, onError, onEnd } = options ?? {};
@@ -2941,7 +3079,9 @@ function createProcessJobsStream(call, options) {
2941
3079
  }
2942
3080
  return {
2943
3081
  send(request) {
2944
- call.write(request);
3082
+ call.write(
3083
+ request.fail ? { ...request, fail: encodeFailRequest(request.fail) } : request
3084
+ );
2945
3085
  },
2946
3086
  receive() {
2947
3087
  return new AsyncIterableStream(call, { onError, onEnd });
@@ -3129,12 +3269,19 @@ function mapGrpcError(error, timeoutMs) {
3129
3269
  return new ServerError(message, 500, "GRPC_ERROR");
3130
3270
  }
3131
3271
  }
3272
+ function grpcHost(address) {
3273
+ if (address.startsWith("[")) {
3274
+ const end = address.indexOf("]");
3275
+ return end === -1 ? address : address.slice(1, end);
3276
+ }
3277
+ return address.split(":").length > 2 ? address : address.split(":")[0];
3278
+ }
3132
3279
  function shouldUseTls(address, explicitTls) {
3133
3280
  if (explicitTls !== void 0) {
3134
3281
  return explicitTls;
3135
3282
  }
3136
- const host = address.split(":")[0].toLowerCase();
3137
- return host !== "localhost" && host !== "127.0.0.1" && !host.startsWith("[::1]");
3283
+ const host = grpcHost(address).toLowerCase();
3284
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
3138
3285
  }
3139
3286
  var GrpcQueueOperations = class {
3140
3287
  constructor(client, metadata, timeoutMs) {
@@ -3186,7 +3333,7 @@ var GrpcQueueOperations = class {
3186
3333
  return promisify(
3187
3334
  this.client,
3188
3335
  this.client.Fail,
3189
- params,
3336
+ encodeFailRequest(params),
3190
3337
  this.metadata,
3191
3338
  this.timeoutMs
3192
3339
  );
@@ -3576,6 +3723,13 @@ var SpooledClient = class _SpooledClient {
3576
3723
  /**
3577
3724
  * Exchange the API key for a JWT via /auth/login and cache the result.
3578
3725
  *
3726
+ * The JWT belongs to the realtime transport only. It must NOT replace the
3727
+ * REST bearer: an API-key client has no refresh token wired into its HTTP
3728
+ * layer, so after the JWT's 24 h lifetime every REST call (claim, complete,
3729
+ * heartbeat, create) would have failed with 401 for good. REST keeps using
3730
+ * the API key; realtime re-mints its own JWT near expiry (see
3731
+ * `getJwtToken`).
3732
+ *
3579
3733
  * Concurrent callers share a single in-flight login so a burst of reconnects
3580
3734
  * (e.g. WebSocket and SSE at once) issues just one request.
3581
3735
  */
@@ -3585,9 +3739,6 @@ var SpooledClient = class _SpooledClient {
3585
3739
  }
3586
3740
  this.jwtLoginPromise = (async () => {
3587
3741
  const response = await this.auth.login({ apiKey: this.config.apiKey });
3588
- this.http.setAuthToken(response.accessToken);
3589
- this.tokenExpiresAt = Date.now() + response.expiresIn * 1e3;
3590
- this.config.refreshToken = response.refreshToken;
3591
3742
  this.cachedJwt = response.accessToken;
3592
3743
  return response.accessToken;
3593
3744
  })();
@@ -3783,6 +3934,9 @@ var DEFAULT_OPTIONS = {
3783
3934
  autoStart: false,
3784
3935
  shutdownTimeout: 3e4
3785
3936
  };
3937
+ var JOB_TIMEOUT_MESSAGE = "Job timeout exceeded";
3938
+ var SHUTDOWN_TIMEOUT_MESSAGE = "Worker shutdown timeout";
3939
+ var MAX_TIMER_MS = 2 ** 31 - 1;
3786
3940
  var SpooledWorker = class {
3787
3941
  client;
3788
3942
  /**
@@ -3799,6 +3953,10 @@ var SpooledWorker = class {
3799
3953
  pollTimer = null;
3800
3954
  workerHeartbeatTimer = null;
3801
3955
  shutdownPromise = null;
3956
+ /** The claim request currently on the wire, so a stop can wait for its jobs. */
3957
+ pollInFlight = null;
3958
+ /** Wakes the shutdown drain when the last active job has been acked. */
3959
+ drainWaiter = null;
3802
3960
  // Event handlers
3803
3961
  eventHandlers = /* @__PURE__ */ new Map();
3804
3962
  constructor(client, options) {
@@ -3815,7 +3973,11 @@ var SpooledWorker = class {
3815
3973
  this.debug = config.debug ?? (() => {
3816
3974
  });
3817
3975
  if (this.options.autoStart) {
3818
- setImmediate(() => this.start());
3976
+ setImmediate(() => {
3977
+ this.start().catch((error) => {
3978
+ this.debug("Auto-start failed", error);
3979
+ });
3980
+ });
3819
3981
  }
3820
3982
  }
3821
3983
  /**
@@ -3886,7 +4048,15 @@ var SpooledWorker = class {
3886
4048
  }
3887
4049
  }
3888
4050
  /**
3889
- * Stop the worker gracefully
4051
+ * Stop the worker gracefully.
4052
+ *
4053
+ * Drains: the worker stops claiming, then lets running handlers finish for
4054
+ * up to `shutdownTimeout`. A handler that returns completes its job and one
4055
+ * that throws fails it, even though a stop was requested meanwhile; its
4056
+ * `signal` is NOT aborted during the grace period. Only handlers still
4057
+ * running when the timeout ends are aborted and failed with "Worker shutdown
4058
+ * timeout". The worker deregisters last, because deregistering returns every
4059
+ * job the worker still holds to the queue.
3890
4060
  */
3891
4061
  async stop() {
3892
4062
  if (this.state !== "running") {
@@ -3899,39 +4069,37 @@ var SpooledWorker = class {
3899
4069
  return this.shutdownPromise;
3900
4070
  }
3901
4071
  async doStop() {
4072
+ const deadline = Date.now() + this.options.shutdownTimeout;
3902
4073
  this.state = "stopping";
3903
4074
  this.debug("Stopping worker...");
3904
4075
  if (this.pollTimer) {
3905
4076
  clearTimeout(this.pollTimer);
3906
4077
  this.pollTimer = null;
3907
4078
  }
3908
- if (this.workerHeartbeatTimer) {
3909
- clearInterval(this.workerHeartbeatTimer);
3910
- this.workerHeartbeatTimer = null;
3911
- }
3912
- for (const active of this.activeJobs.values()) {
3913
- active.abortController.abort();
4079
+ if (this.pollInFlight) {
4080
+ await this.pollInFlight;
3914
4081
  }
3915
4082
  if (this.activeJobs.size > 0) {
3916
4083
  this.debug(
3917
4084
  `Waiting for ${this.activeJobs.size} active jobs to complete...`
3918
4085
  );
3919
- await Promise.race([
3920
- this.waitForActiveJobs(),
3921
- this.sleep(this.options.shutdownTimeout)
3922
- ]);
3923
- const remaining = Array.from(this.activeJobs.values());
3924
- for (const active of remaining) {
3925
- this.debug(
3926
- `Force-failing job ${active.job.id} due to shutdown timeout`
3927
- );
3928
- await this.failJob(active.job, "Worker shutdown timeout");
3929
- this.cleanupJob(active);
3930
- }
4086
+ await this.waitForDrain(Math.max(0, deadline - Date.now()));
4087
+ const stragglers = Array.from(this.activeJobs.values()).filter(
4088
+ (active) => !active.settled
4089
+ );
4090
+ await Promise.all(stragglers.map((active) => this.abandonJob(active)));
4091
+ await Promise.all(
4092
+ Array.from(this.activeJobs.values()).map((active) => active.ack)
4093
+ );
4094
+ }
4095
+ if (this.workerHeartbeatTimer) {
4096
+ clearInterval(this.workerHeartbeatTimer);
4097
+ this.workerHeartbeatTimer = null;
3931
4098
  }
3932
- if (this.workerId) {
4099
+ const workerId = this.workerId;
4100
+ if (workerId) {
3933
4101
  try {
3934
- await this.client.workers.deregister(this.workerId);
4102
+ await this.client.workers.deregister(workerId);
3935
4103
  this.debug("Worker deregistered");
3936
4104
  } catch (error) {
3937
4105
  this.debug("Failed to deregister worker", error);
@@ -3939,7 +4107,7 @@ var SpooledWorker = class {
3939
4107
  }
3940
4108
  this.state = "stopped";
3941
4109
  this.workerId = null;
3942
- this.emit("stopped", { workerId: this.workerId ?? "", reason: "graceful" });
4110
+ this.emit("stopped", { workerId: workerId ?? "", reason: "graceful" });
3943
4111
  this.shutdownPromise = null;
3944
4112
  }
3945
4113
  /**
@@ -3978,7 +4146,17 @@ var SpooledWorker = class {
3978
4146
  if (this.state !== "running") {
3979
4147
  return;
3980
4148
  }
3981
- this.pollTimer = setTimeout(() => this.poll(), this.options.pollInterval);
4149
+ this.pollTimer = setTimeout(() => {
4150
+ this.pollTimer = null;
4151
+ const run = this.poll();
4152
+ this.pollInFlight = run;
4153
+ const done = () => {
4154
+ if (this.pollInFlight === run) {
4155
+ this.pollInFlight = null;
4156
+ }
4157
+ };
4158
+ run.then(done, done);
4159
+ }, this.options.pollInterval);
3982
4160
  }
3983
4161
  async poll() {
3984
4162
  if (this.state !== "running" || !this.workerId) {
@@ -4012,7 +4190,8 @@ var SpooledWorker = class {
4012
4190
  executionId: Symbol(job.id),
4013
4191
  job,
4014
4192
  startedAt: /* @__PURE__ */ new Date(),
4015
- abortController
4193
+ abortController,
4194
+ settled: false
4016
4195
  };
4017
4196
  this.activeJobs.set(activeJob.executionId, activeJob);
4018
4197
  const heartbeatInterval = this.options.leaseDuration * this.options.heartbeatFraction * 1e3;
@@ -4041,23 +4220,72 @@ var SpooledWorker = class {
4041
4220
  }
4042
4221
  };
4043
4222
  try {
4044
- const result = await this.handler(context);
4045
- if (signal.aborted) {
4046
- this.debug(`Job ${job.id} was aborted`);
4223
+ const outcome = await this.runHandler(activeJob, context);
4224
+ if (activeJob.settled) {
4225
+ this.debug(`Job ${job.id} was already settled; dropping late result`);
4047
4226
  return;
4048
4227
  }
4049
- await this.completeJob(job, result ?? void 0);
4050
- } catch (error) {
4051
- if (signal.aborted) {
4052
- this.debug(`Job ${job.id} was aborted`);
4053
- return;
4228
+ activeJob.settled = true;
4229
+ switch (outcome.kind) {
4230
+ case "returned":
4231
+ activeJob.ack = this.completeJob(job, outcome.value ?? void 0);
4232
+ break;
4233
+ case "threw":
4234
+ activeJob.ack = this.failJob(
4235
+ job,
4236
+ outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
4237
+ );
4238
+ break;
4239
+ case "timeout":
4240
+ abortController.abort(new Error(JOB_TIMEOUT_MESSAGE));
4241
+ this.emit("job:timeout", {
4242
+ jobId: job.id,
4243
+ queueName: job.queueName
4244
+ });
4245
+ activeJob.ack = this.failJob(job, JOB_TIMEOUT_MESSAGE);
4246
+ break;
4054
4247
  }
4055
- const errorMessage = error instanceof Error ? error.message : String(error);
4056
- await this.failJob(job, errorMessage);
4248
+ await activeJob.ack;
4057
4249
  } finally {
4058
4250
  this.cleanupJob(activeJob);
4059
4251
  }
4060
4252
  }
4253
+ /**
4254
+ * Run the handler, racing it against the job's `timeoutSeconds`.
4255
+ * Never rejects: a throw (sync or async) is reported as an outcome.
4256
+ */
4257
+ runHandler(activeJob, context) {
4258
+ const run = (async () => {
4259
+ try {
4260
+ return { kind: "returned", value: await this.handler(context) };
4261
+ } catch (error) {
4262
+ return { kind: "threw", error };
4263
+ }
4264
+ })();
4265
+ const { timeoutSeconds } = activeJob.job;
4266
+ if (typeof timeoutSeconds !== "number" || !(timeoutSeconds > 0)) {
4267
+ return run;
4268
+ }
4269
+ return new Promise((resolve) => {
4270
+ activeJob.timeoutTimer = setTimeout(
4271
+ () => resolve({ kind: "timeout" }),
4272
+ Math.min(timeoutSeconds * 1e3, MAX_TIMER_MS)
4273
+ );
4274
+ run.then((outcome) => {
4275
+ this.clearTimeoutTimer(activeJob);
4276
+ resolve(outcome);
4277
+ });
4278
+ });
4279
+ }
4280
+ /** Abort a handler that outlived the shutdown grace period and fail its job. */
4281
+ async abandonJob(activeJob) {
4282
+ this.debug(`Force-failing job ${activeJob.job.id} due to shutdown timeout`);
4283
+ activeJob.settled = true;
4284
+ activeJob.abortController.abort(new Error(SHUTDOWN_TIMEOUT_MESSAGE));
4285
+ activeJob.ack = this.failJob(activeJob.job, SHUTDOWN_TIMEOUT_MESSAGE);
4286
+ await activeJob.ack;
4287
+ this.cleanupJob(activeJob);
4288
+ }
4061
4289
  async completeJob(job, result) {
4062
4290
  if (!this.workerId) return;
4063
4291
  try {
@@ -4073,25 +4301,34 @@ var SpooledWorker = class {
4073
4301
  });
4074
4302
  } catch (error) {
4075
4303
  this.debug(`Failed to complete job ${job.id}`, error);
4304
+ this.emit("error", { error });
4076
4305
  }
4077
4306
  }
4078
4307
  async failJob(job, errorMessage) {
4079
4308
  if (!this.workerId) return;
4080
4309
  const willRetry = job.retryCount < job.maxRetries;
4310
+ const message = coerceFailureMessage(errorMessage);
4081
4311
  try {
4082
4312
  await this.client.jobs.fail(job.id, {
4083
4313
  workerId: this.workerId,
4084
- error: errorMessage,
4314
+ error: message,
4085
4315
  ...job.leaseId != null && { leaseId: job.leaseId }
4086
4316
  });
4087
4317
  this.emit("job:failed", {
4088
4318
  jobId: job.id,
4089
4319
  queueName: job.queueName,
4090
- error: errorMessage,
4320
+ error: message,
4091
4321
  willRetry
4092
4322
  });
4093
4323
  } catch (error) {
4094
4324
  this.debug(`Failed to fail job ${job.id}`, error);
4325
+ this.emit("error", { error });
4326
+ }
4327
+ }
4328
+ clearTimeoutTimer(activeJob) {
4329
+ if (activeJob.timeoutTimer) {
4330
+ clearTimeout(activeJob.timeoutTimer);
4331
+ activeJob.timeoutTimer = void 0;
4095
4332
  }
4096
4333
  }
4097
4334
  cleanupJob(activeJob) {
@@ -4099,8 +4336,12 @@ var SpooledWorker = class {
4099
4336
  clearInterval(activeJob.heartbeatTimer);
4100
4337
  activeJob.heartbeatTimer = void 0;
4101
4338
  }
4339
+ this.clearTimeoutTimer(activeJob);
4102
4340
  if (this.activeJobs.get(activeJob.executionId) === activeJob) {
4103
4341
  this.activeJobs.delete(activeJob.executionId);
4342
+ if (this.activeJobs.size === 0) {
4343
+ this.drainWaiter?.();
4344
+ }
4104
4345
  }
4105
4346
  }
4106
4347
  async sendJobHeartbeat(activeJob) {
@@ -4117,7 +4358,9 @@ var SpooledWorker = class {
4117
4358
  }
4118
4359
  }
4119
4360
  async sendWorkerHeartbeat() {
4120
- if (!this.workerId || this.state !== "running") return;
4361
+ if (!this.workerId || this.state !== "running" && this.state !== "stopping") {
4362
+ return;
4363
+ }
4121
4364
  try {
4122
4365
  await this.client.workers.heartbeat(this.workerId, {
4123
4366
  currentJobs: this.activeJobs.size,
@@ -4127,16 +4370,28 @@ var SpooledWorker = class {
4127
4370
  this.debug("Worker heartbeat failed", error);
4128
4371
  }
4129
4372
  }
4130
- async waitForActiveJobs() {
4131
- while (this.activeJobs.size > 0) {
4132
- await this.sleep(100);
4373
+ /**
4374
+ * Resolve once no job is active, or after `timeoutMs`. The timer is always
4375
+ * cleared, so a clean stop leaves nothing that keeps the process alive.
4376
+ */
4377
+ waitForDrain(timeoutMs) {
4378
+ if (this.activeJobs.size === 0) {
4379
+ return Promise.resolve();
4133
4380
  }
4134
- }
4135
- sleep(ms) {
4136
- return new Promise((resolve) => setTimeout(resolve, ms));
4381
+ return new Promise((resolve) => {
4382
+ const timer = setTimeout(() => {
4383
+ this.drainWaiter = null;
4384
+ resolve();
4385
+ }, timeoutMs);
4386
+ this.drainWaiter = () => {
4387
+ clearTimeout(timer);
4388
+ this.drainWaiter = null;
4389
+ resolve();
4390
+ };
4391
+ });
4137
4392
  }
4138
4393
  };
4139
4394
 
4140
- export { API_VERSION, AdminResource, ApiKeysResource, AuthResource, AuthenticationError, AuthorizationError, BillingResource, CircuitBreaker, CircuitBreakerOpenError, CircuitState, ConflictError, DEFAULT_CONFIG, DashboardResource, GrpcJobStatus, GrpcJobStatusNumber, GrpcQueueOperations, GrpcWorkerOperations, HealthResource, HttpClient, JobsResource, MetricsResource, NetworkError, NotFoundError, OrganizationsResource, PROTO_LOADER_OPTIONS, PayloadTooLargeError, QueuesResource, RateLimitError, SDK_VERSION, SchedulesResource, ServerError, SpooledClient, SpooledError, SpooledGrpcClient, SpooledRealtime, SpooledWorker, SseRealtimeClient, TimeoutError, ValidationError, WebSocketRealtimeClient, WebhookIngestionResource, WebhooksResource, WorkersResource, WorkflowsResource, asyncIterableFromStream, calculateDelay, camelToSnake, clearProtoCache, convertQueryParams, convertRequest, convertResponse, createCircuitBreaker, createClient, createErrorFromResponse, createHttpClient, createJobStream, createPaginatedResponse, createProcessJobsStream, createRetryWrapper, dateToTimestamp, getProtoPath, isRetryableError, isSpooledError, loadProtoDefinition, loadProtoDefinitionAsync, parseRateLimitHeaders, resolveConfig, shouldRetry, sleep, snakeToCamel, timestampToDate, toCamelCase, toSnakeCase, validateConfig, withRetry };
4395
+ export { API_VERSION, AdminResource, ApiKeysResource, AuthResource, AuthenticationError, AuthorizationError, BillingResource, CircuitBreaker, CircuitBreakerOpenError, CircuitState, ConflictError, DEFAULT_CONFIG, DashboardResource, GrpcJobStatus, GrpcJobStatusNumber, GrpcQueueOperations, GrpcWorkerOperations, HealthResource, HttpClient, JobsResource, MetricsResource, NetworkError, NotFoundError, OrganizationsResource, PROTO_LOADER_OPTIONS, PayloadTooLargeError, QueuesResource, RateLimitError, SDK_VERSION, SchedulesResource, ServerError, SpooledClient, SpooledError, SpooledGrpcClient, SpooledRealtime, SpooledWorker, SseRealtimeClient, TimeoutError, UnsupportedOperationError, ValidationError, WebSocketRealtimeClient, WebhookIngestionResource, WebhooksResource, WorkersResource, WorkflowsResource, asyncIterableFromStream, calculateDelay, camelToSnake, clearProtoCache, convertQueryParams, convertRequest, convertResponse, createCircuitBreaker, createClient, createErrorFromResponse, createHttpClient, createJobStream, createPaginatedResponse, createProcessJobsStream, createRetryWrapper, dateToTimestamp, getProtoPath, isRetryableError, isSpooledError, loadProtoDefinition, loadProtoDefinitionAsync, parseRateLimitHeaders, resolveConfig, shouldRetry, sleep, snakeToCamel, timestampToDate, toCamelCase, toSnakeCase, validateConfig, withRetry };
4141
4396
  //# sourceMappingURL=index.js.map
4142
4397
  //# sourceMappingURL=index.js.map