@spooled/sdk 1.0.31 → 1.0.33

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/README.md CHANGED
@@ -157,7 +157,7 @@ Run jobs on a cron schedule:
157
157
  ```typescript
158
158
  const schedule = await client.schedules.create({
159
159
  name: 'Daily Report',
160
- cronExpression: '0 0 9 * * *', // 6-field cron
160
+ cronExpression: '0 9 * * *', // 9 AM daily; 5-field (min hour dom mon dow) or 6-field (leading seconds) both work
161
161
  timezone: 'America/New_York',
162
162
  queueName: 'reports',
163
163
  payloadTemplate: { type: 'daily' },
@@ -320,22 +320,25 @@ All operations automatically enforce tier-based limits:
320
320
  - ✅ Queue creation
321
321
  - ✅ Webhook creation
322
322
 
323
- When limits are exceeded, you'll receive a `403 Forbidden` response with details:
323
+ When a plan quota or limit is exceeded, you'll receive an `HTTP 429` response with
324
+ `code: "QUOTA_EXCEEDED"` and a body describing the `resource`, `current`, `limit`, and `plan`:
324
325
 
325
326
  ```typescript
326
- import { AuthorizationError } from '@spooled/sdk';
327
+ import { RateLimitError } from '@spooled/sdk';
327
328
 
328
329
  try {
329
330
  await client.jobs.create({ /* ... */ });
330
331
  } catch (error) {
331
- if (error instanceof AuthorizationError) {
332
- // Plan limit reached (HTTP 403); the message describes the limit
332
+ if (error instanceof RateLimitError && error.code === 'QUOTA_EXCEEDED') {
333
+ // Plan quota reached (HTTP 429); details carry resource/current/limit/plan
333
334
  console.log(`Plan limit: ${error.message}`);
334
335
  console.log('Details:', error.details);
335
336
  }
336
337
  }
337
338
  ```
338
339
 
340
+ (Per-second rate limiting also returns `429`, as a `RateLimitError` with the default `RATE_LIMIT_EXCEEDED` code.)
341
+
339
342
  ## Error Handling
340
343
 
341
344
  All errors extend `SpooledError` with specific subclasses:
@@ -355,8 +358,9 @@ try {
355
358
  if (error instanceof NotFoundError) {
356
359
  console.log('Job not found');
357
360
  } else if (error instanceof AuthorizationError) {
358
- console.log(`Forbidden (e.g. plan limit): ${error.message}`);
361
+ console.log(`Forbidden: ${error.message}`);
359
362
  } else if (error instanceof RateLimitError) {
363
+ // 429: either a plan quota (code "QUOTA_EXCEEDED") or per-second rate limiting
360
364
  console.log(`Retry after ${error.getRetryAfter()} seconds`);
361
365
  } else if (isSpooledError(error)) {
362
366
  console.log(`API error: ${error.code} - ${error.message}`);
@@ -107,11 +107,11 @@ declare const DEFAULT_CONFIG: {
107
107
  successThreshold: number;
108
108
  timeout: number;
109
109
  };
110
- readonly userAgent: "@spooled/sdk-nodejs/1.0.31";
110
+ readonly userAgent: "@spooled/sdk-nodejs/1.0.33";
111
111
  readonly autoRefreshToken: true;
112
112
  };
113
113
  /** SDK version */
114
- declare const SDK_VERSION = "1.0.31";
114
+ declare const SDK_VERSION = "1.0.33";
115
115
  /** API version prefix */
116
116
  declare const API_VERSION = "v1";
117
117
  /**
@@ -2315,6 +2315,26 @@ interface JobStatusChangedEvent extends RealtimeEventBase {
2315
2315
  newStatus: JobStatus;
2316
2316
  };
2317
2317
  }
2318
+ /** Queue stats update event (backend `QueueStats`) */
2319
+ interface QueueStatsEvent extends RealtimeEventBase {
2320
+ type: 'queue.stats';
2321
+ data: {
2322
+ queueName: string;
2323
+ pending: number;
2324
+ processing: number;
2325
+ completed: number;
2326
+ failed: number;
2327
+ };
2328
+ }
2329
+ /** Worker heartbeat event (backend `WorkerHeartbeat`) */
2330
+ interface WorkerHeartbeatEvent extends RealtimeEventBase {
2331
+ type: 'worker.heartbeat';
2332
+ data: {
2333
+ workerId: string;
2334
+ status: string;
2335
+ currentJobId?: string;
2336
+ };
2337
+ }
2318
2338
  /** Queue paused event */
2319
2339
  interface QueuePausedEvent extends RealtimeEventBase {
2320
2340
  type: 'queue.paused';
@@ -2363,7 +2383,7 @@ interface HeartbeatEvent extends RealtimeEventBase {
2363
2383
  };
2364
2384
  }
2365
2385
  /** All realtime event types */
2366
- type RealtimeEvent = JobCreatedEvent | JobStartedEvent | JobCompletedEvent | JobFailedEvent | JobProgressEvent | JobStatusChangedEvent | QueuePausedEvent | QueueResumedEvent | WorkerRegisteredEvent | WorkerDeregisteredEvent | ScheduleTriggeredEvent | HeartbeatEvent;
2386
+ type RealtimeEvent = JobCreatedEvent | JobStartedEvent | JobCompletedEvent | JobFailedEvent | JobProgressEvent | JobStatusChangedEvent | QueueStatsEvent | QueuePausedEvent | QueueResumedEvent | WorkerRegisteredEvent | WorkerDeregisteredEvent | WorkerHeartbeatEvent | ScheduleTriggeredEvent | HeartbeatEvent;
2367
2387
  /** Event type names */
2368
2388
  type RealtimeEventType = RealtimeEvent['type'];
2369
2389
  /** Extract event data type from event type name */
@@ -107,11 +107,11 @@ declare const DEFAULT_CONFIG: {
107
107
  successThreshold: number;
108
108
  timeout: number;
109
109
  };
110
- readonly userAgent: "@spooled/sdk-nodejs/1.0.31";
110
+ readonly userAgent: "@spooled/sdk-nodejs/1.0.33";
111
111
  readonly autoRefreshToken: true;
112
112
  };
113
113
  /** SDK version */
114
- declare const SDK_VERSION = "1.0.31";
114
+ declare const SDK_VERSION = "1.0.33";
115
115
  /** API version prefix */
116
116
  declare const API_VERSION = "v1";
117
117
  /**
@@ -2315,6 +2315,26 @@ interface JobStatusChangedEvent extends RealtimeEventBase {
2315
2315
  newStatus: JobStatus;
2316
2316
  };
2317
2317
  }
2318
+ /** Queue stats update event (backend `QueueStats`) */
2319
+ interface QueueStatsEvent extends RealtimeEventBase {
2320
+ type: 'queue.stats';
2321
+ data: {
2322
+ queueName: string;
2323
+ pending: number;
2324
+ processing: number;
2325
+ completed: number;
2326
+ failed: number;
2327
+ };
2328
+ }
2329
+ /** Worker heartbeat event (backend `WorkerHeartbeat`) */
2330
+ interface WorkerHeartbeatEvent extends RealtimeEventBase {
2331
+ type: 'worker.heartbeat';
2332
+ data: {
2333
+ workerId: string;
2334
+ status: string;
2335
+ currentJobId?: string;
2336
+ };
2337
+ }
2318
2338
  /** Queue paused event */
2319
2339
  interface QueuePausedEvent extends RealtimeEventBase {
2320
2340
  type: 'queue.paused';
@@ -2363,7 +2383,7 @@ interface HeartbeatEvent extends RealtimeEventBase {
2363
2383
  };
2364
2384
  }
2365
2385
  /** All realtime event types */
2366
- type RealtimeEvent = JobCreatedEvent | JobStartedEvent | JobCompletedEvent | JobFailedEvent | JobProgressEvent | JobStatusChangedEvent | QueuePausedEvent | QueueResumedEvent | WorkerRegisteredEvent | WorkerDeregisteredEvent | ScheduleTriggeredEvent | HeartbeatEvent;
2386
+ type RealtimeEvent = JobCreatedEvent | JobStartedEvent | JobCompletedEvent | JobFailedEvent | JobProgressEvent | JobStatusChangedEvent | QueueStatsEvent | QueuePausedEvent | QueueResumedEvent | WorkerRegisteredEvent | WorkerDeregisteredEvent | WorkerHeartbeatEvent | ScheduleTriggeredEvent | HeartbeatEvent;
2367
2387
  /** Event type names */
2368
2388
  type RealtimeEventType = RealtimeEvent['type'];
2369
2389
  /** Extract event data type from event type name */
package/dist/index.cjs CHANGED
@@ -49,12 +49,19 @@ var DEFAULT_CONFIG = {
49
49
  successThreshold: 3,
50
50
  timeout: 3e4
51
51
  },
52
- userAgent: "@spooled/sdk-nodejs/1.0.31",
52
+ userAgent: "@spooled/sdk-nodejs/1.0.33",
53
53
  autoRefreshToken: true
54
54
  };
55
- var SDK_VERSION = "1.0.31";
55
+ var SDK_VERSION = "1.0.33";
56
56
  var API_VERSION = "v1";
57
57
  var API_BASE_PATH = `/api/${API_VERSION}`;
58
+ function normalizeCredential(value) {
59
+ if (value === void 0) {
60
+ return void 0;
61
+ }
62
+ const trimmed = value.trim();
63
+ return trimmed === "" ? void 0 : trimmed;
64
+ }
58
65
  function resolveConfig(options) {
59
66
  let debugFn = null;
60
67
  if (options.debug === true) {
@@ -84,10 +91,10 @@ function resolveConfig(options) {
84
91
  const derivedWsUrl = baseUrl.replace(/^http/, "ws");
85
92
  const wsUrl = options.wsUrl ?? derivedWsUrl;
86
93
  return {
87
- apiKey: options.apiKey,
88
- accessToken: options.accessToken,
89
- refreshToken: options.refreshToken,
90
- adminKey: options.adminKey,
94
+ apiKey: normalizeCredential(options.apiKey),
95
+ accessToken: normalizeCredential(options.accessToken),
96
+ refreshToken: normalizeCredential(options.refreshToken),
97
+ adminKey: normalizeCredential(options.adminKey),
91
98
  baseUrl,
92
99
  wsUrl,
93
100
  grpcAddress: options.grpcAddress ?? DEFAULT_CONFIG.grpcAddress,
@@ -320,22 +327,33 @@ function parseRateLimitHeaders(headers) {
320
327
  }
321
328
  return info;
322
329
  }
330
+ var MAX_RAW_ERROR_BODY_CHARS = 500;
323
331
  async function parseErrorBody(response) {
324
332
  const contentType = response.headers.get("Content-Type") || "";
325
- if (!contentType.includes("application/json")) {
333
+ let raw;
334
+ try {
335
+ raw = await response.text();
336
+ } catch {
326
337
  return null;
327
338
  }
328
- try {
329
- const body = await response.json();
330
- if (typeof body === "object" && body !== null) {
331
- const message = (body.message ?? body.error) || response.statusText;
332
- return {
333
- code: body.code || "UNKNOWN_ERROR",
334
- message,
335
- details: body.details
336
- };
339
+ if (contentType.includes("application/json")) {
340
+ try {
341
+ const body = JSON.parse(raw);
342
+ if (typeof body === "object" && body !== null) {
343
+ const message = (body.message ?? body.error) || response.statusText;
344
+ return {
345
+ code: body.code || "UNKNOWN_ERROR",
346
+ message,
347
+ details: body.details
348
+ };
349
+ }
350
+ } catch {
337
351
  }
338
- } catch {
352
+ }
353
+ const trimmed = raw.trim();
354
+ if (trimmed) {
355
+ const message = trimmed.length > MAX_RAW_ERROR_BODY_CHARS ? `${trimmed.slice(0, MAX_RAW_ERROR_BODY_CHARS)}\u2026` : trimmed;
356
+ return { code: "UNKNOWN_ERROR", message };
339
357
  }
340
358
  return null;
341
359
  }
@@ -1841,6 +1859,24 @@ var WebhookIngestionResource = class {
1841
1859
  }
1842
1860
  };
1843
1861
 
1862
+ // src/realtime/event-map.ts
1863
+ var BACKEND_EVENT_TYPE_MAP = {
1864
+ JobStatusChange: "job.status_changed",
1865
+ JobCreated: "job.created",
1866
+ JobCompleted: "job.completed",
1867
+ JobFailed: "job.failed",
1868
+ QueueStats: "queue.stats",
1869
+ WorkerHeartbeat: "worker.heartbeat",
1870
+ WorkerRegistered: "worker.registered",
1871
+ WorkerDeregistered: "worker.deregistered",
1872
+ SystemHealth: "system.health",
1873
+ Ping: "ping",
1874
+ Error: "error"
1875
+ };
1876
+ function mapEventType(type) {
1877
+ return BACKEND_EVENT_TYPE_MAP[type] ?? type;
1878
+ }
1879
+
1844
1880
  // src/realtime/websocket.ts
1845
1881
  var WebSocketRealtimeClient = class {
1846
1882
  options;
@@ -1856,7 +1892,6 @@ var WebSocketRealtimeClient = class {
1856
1892
  */
1857
1893
  authFailure = false;
1858
1894
  subscriptions = /* @__PURE__ */ new Map();
1859
- pendingCommands = /* @__PURE__ */ new Map();
1860
1895
  // Event handlers
1861
1896
  eventHandlers = /* @__PURE__ */ new Map();
1862
1897
  allEventsHandlers = /* @__PURE__ */ new Set();
@@ -1961,10 +1996,13 @@ var WebSocketRealtimeClient = class {
1961
1996
  }
1962
1997
  this.setState("disconnected");
1963
1998
  this.subscriptions.clear();
1964
- this.clearPendingCommands();
1965
1999
  }
1966
2000
  /**
1967
- * Subscribe to events matching a filter
2001
+ * Subscribe to events matching a filter.
2002
+ *
2003
+ * The server applies filtering itself and sends no acknowledgement, so this
2004
+ * resolves as soon as the command has been written to the socket. The filter
2005
+ * is tracked locally so it is replayed automatically on reconnect.
1968
2006
  */
1969
2007
  async subscribe(filter) {
1970
2008
  const filterId = this.filterToId(filter);
@@ -1973,11 +2011,13 @@ var WebSocketRealtimeClient = class {
1973
2011
  }
1974
2012
  this.subscriptions.set(filterId, filter);
1975
2013
  if (this.state === "connected") {
1976
- await this.sendCommand({ type: "subscribe", filter });
2014
+ this.sendCommand("Subscribe", filter);
1977
2015
  }
1978
2016
  }
1979
2017
  /**
1980
- * Unsubscribe from events matching a filter
2018
+ * Unsubscribe from events matching a filter.
2019
+ *
2020
+ * Fire-and-forget, like {@link subscribe} — the server sends no ack.
1981
2021
  */
1982
2022
  async unsubscribe(filter) {
1983
2023
  const filterId = this.filterToId(filter);
@@ -1986,7 +2026,7 @@ var WebSocketRealtimeClient = class {
1986
2026
  }
1987
2027
  this.subscriptions.delete(filterId);
1988
2028
  if (this.state === "connected") {
1989
- await this.sendCommand({ type: "unsubscribe", filter });
2029
+ this.sendCommand("Unsubscribe", filter);
1990
2030
  }
1991
2031
  }
1992
2032
  /**
@@ -2039,13 +2079,11 @@ var WebSocketRealtimeClient = class {
2039
2079
  handleMessage(data) {
2040
2080
  try {
2041
2081
  const message = convertResponse(JSON.parse(data));
2042
- if (message.type === "subscribed" || message.type === "unsubscribed" || message.type === "error") {
2043
- this.handleCommandResponse(message);
2044
- return;
2045
- }
2082
+ const eventType = mapEventType(String(message.type));
2083
+ message.type = eventType;
2046
2084
  const event = message;
2047
- this.options.debug(`Received event: ${event.type}`, event);
2048
- const handlers = this.eventHandlers.get(event.type);
2085
+ this.options.debug(`Received event: ${eventType}`, event);
2086
+ const handlers = this.eventHandlers.get(eventType);
2049
2087
  if (handlers) {
2050
2088
  const eventData = event.data;
2051
2089
  handlers.forEach((handler) => {
@@ -2067,25 +2105,8 @@ var WebSocketRealtimeClient = class {
2067
2105
  this.options.debug("Failed to parse message", { data, error });
2068
2106
  }
2069
2107
  }
2070
- handleCommandResponse(response) {
2071
- if (!response.requestId) {
2072
- return;
2073
- }
2074
- const pending = this.pendingCommands.get(response.requestId);
2075
- if (!pending) {
2076
- return;
2077
- }
2078
- clearTimeout(pending.timeout);
2079
- this.pendingCommands.delete(response.requestId);
2080
- if (response.type === "error") {
2081
- pending.reject(new Error(response.error || "Unknown error"));
2082
- } else {
2083
- pending.resolve();
2084
- }
2085
- }
2086
2108
  handleDisconnect() {
2087
2109
  this.ws = null;
2088
- this.clearPendingCommands();
2089
2110
  if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
2090
2111
  this.scheduleReconnect();
2091
2112
  } else {
@@ -2109,43 +2130,37 @@ var WebSocketRealtimeClient = class {
2109
2130
  }
2110
2131
  }, delay);
2111
2132
  }
2112
- async resubscribeAll() {
2133
+ resubscribeAll() {
2113
2134
  for (const filter of this.subscriptions.values()) {
2114
2135
  try {
2115
- await this.sendCommand({ type: "subscribe", filter });
2136
+ this.sendCommand("Subscribe", filter);
2116
2137
  } catch (error) {
2117
2138
  this.options.debug("Failed to resubscribe", { filter, error });
2118
2139
  }
2119
2140
  }
2120
2141
  }
2121
- async sendCommand(command) {
2142
+ /**
2143
+ * Send a subscribe/unsubscribe command to the server.
2144
+ *
2145
+ * The backend `ClientCommand` shape is `{ cmd, queue, job_id }` and it sends
2146
+ * no acknowledgement, so this is fire-and-forget — it writes to the socket
2147
+ * and returns. The `SubscriptionFilter`'s `workerId`/`scheduleId` have no
2148
+ * server-side equivalent and are ignored.
2149
+ */
2150
+ sendCommand(cmd, filter) {
2122
2151
  if (!this.ws || this.state !== "connected") {
2123
2152
  throw new Error("WebSocket not connected");
2124
2153
  }
2125
- const requestId = this.generateRequestId();
2126
- const fullCommand = { ...command, requestId };
2127
- return new Promise((resolve, reject) => {
2128
- const timeout = setTimeout(() => {
2129
- this.pendingCommands.delete(requestId);
2130
- reject(new Error("Command timeout"));
2131
- }, 1e4);
2132
- this.pendingCommands.set(requestId, { resolve, reject, timeout });
2133
- this.ws.send(JSON.stringify(fullCommand));
2134
- });
2135
- }
2136
- clearPendingCommands() {
2137
- for (const pending of this.pendingCommands.values()) {
2138
- clearTimeout(pending.timeout);
2139
- pending.reject(new Error("Connection closed"));
2140
- }
2141
- this.pendingCommands.clear();
2154
+ const command = {
2155
+ cmd,
2156
+ queue: filter.queueName,
2157
+ job_id: filter.jobId
2158
+ };
2159
+ this.ws.send(JSON.stringify(command));
2142
2160
  }
2143
2161
  filterToId(filter) {
2144
2162
  return JSON.stringify(filter);
2145
2163
  }
2146
- generateRequestId() {
2147
- return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
2148
- }
2149
2164
  };
2150
2165
  function redactToken(url) {
2151
2166
  return url.replace(/([?&](?:token|api_key)=)[^&]*/gi, "$1***");
@@ -2344,9 +2359,12 @@ var SseRealtimeClient = class {
2344
2359
  }
2345
2360
  handleMessage(data) {
2346
2361
  try {
2347
- const event = convertResponse(JSON.parse(data));
2348
- this.options.debug(`Received SSE event: ${event.type}`, event);
2349
- const handlers = this.eventHandlers.get(event.type);
2362
+ const parsed = convertResponse(JSON.parse(data));
2363
+ const eventType = mapEventType(String(parsed.type));
2364
+ parsed.type = eventType;
2365
+ const event = parsed;
2366
+ this.options.debug(`Received SSE event: ${eventType}`, event);
2367
+ const handlers = this.eventHandlers.get(eventType);
2350
2368
  if (handlers) {
2351
2369
  const eventData = event.data;
2352
2370
  handlers.forEach((handler) => {