@spooled/sdk 1.2.1 → 1.4.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/README.md CHANGED
@@ -21,6 +21,7 @@ Official Node.js/TypeScript SDK for [Spooled Cloud](https://spooled.cloud) - a m
21
21
  - **Organization management** with usage tracking
22
22
  - **Webhook management** with automatic retries
23
23
  - **Dead Letter Queue (DLQ)** operations
24
+ - **Server-side job search** (status lists, text, job type, creation time), paging with `hasMore`, and **job history**
24
25
  - **API Key management** with Redis caching (~28x faster authentication)
25
26
  - **Billing integration** via Stripe
26
27
  - **Automatic case conversion** between camelCase (SDK) and snake_case (API)
@@ -109,6 +110,24 @@ const { id } = await client.jobs.create({
109
110
  });
110
111
  ```
111
112
 
113
+ Find jobs on the server and page through them (API 0.1.122+):
114
+
115
+ ```typescript
116
+ const page = await client.jobs.listPage({
117
+ status: ["failed", "deadletter"], // one status or several
118
+ q: "invoice", // id prefix, or text in the queue name or payload.job_type
119
+ jobType: "send-invoice", // exact payload.job_type
120
+ createdAfter: new Date(Date.now() - 86_400_000), // Date or RFC 3339 string
121
+ limit: 100,
122
+ });
123
+ console.log(page.data.length, page.hasMore); // hasMore comes from X-Has-More
124
+
125
+ // What happened to a job: claims, completions, retries, dead-lettering
126
+ const history = await client.jobs.history(id, { order: "desc" });
127
+ ```
128
+
129
+ `jobs.list()` takes the same filters and still returns a plain array.
130
+
112
131
  ### Workers
113
132
 
114
133
  Process jobs with the built-in worker runtime:
@@ -136,10 +155,30 @@ await worker.start();
136
155
  process.on("SIGTERM", () => worker.stop());
137
156
  ```
138
157
 
158
+ `stop()` drains. The worker stops claiming, then lets running handlers finish for up to
159
+ `shutdownTimeout` (default 30 s). A handler that returns is completed and one that throws is
160
+ failed, even though the stop was requested meanwhile; `ctx.signal` is not aborted during that
161
+ grace period. Handlers still running when it ends are aborted and failed with "Worker shutdown
162
+ timeout". The worker deregisters last.
163
+
164
+ If a job has `timeoutSeconds`, the worker aborts the handler (`ctx.signal`) when it runs longer,
165
+ emits `job:timeout`, and fails the job with "Job timeout exceeded". The error text sent with a
166
+ failure is always made acceptable to the API: empty becomes "Job failed", and text over 2048
167
+ characters is truncated.
168
+
169
+ `complete`, `fail` and both heartbeats are retried on network errors, timeouts and 5xx, because
170
+ the server makes them idempotent. If an ack still fails after the retries the worker emits `error`.
171
+
139
172
  Give any worker that restarts a stable `workerId` (1-128 chars, `[A-Za-z0-9._-]`). Registration then
140
173
  upserts a single row instead of minting a new UUID each start, which otherwise leaves the previous
141
174
  registration counting against your plan's worker limit until the stale-worker reaper clears it
142
- (~2 minutes).
175
+ (~2 minutes). `workers.get()` and `workers.list()` report `jobsProcessed` and `jobsFailed` per worker
176
+ (API 0.1.122+).
177
+
178
+ If you process jobs without `SpooledWorker` (`jobs.claim()` and then `jobs.complete()`,
179
+ `jobs.fail()` or `jobs.heartbeat()`), pass the claimed job's `leaseId` on every one of those calls.
180
+ The API requires it: a call without it fails with a `ValidationError` (HTTP 422), and a stale lease
181
+ with HTTP 409. `SpooledWorker` does this for you.
143
182
 
144
183
  ### Workflows (DAGs)
145
184
 
@@ -203,9 +242,9 @@ Manage your organization and track usage:
203
242
  // Get current usage and limits
204
243
  const usage = await client.organizations.getUsage();
205
244
  console.log(
206
- `Active jobs: ${usage.active_jobs.current}/${usage.active_jobs.limit}`,
245
+ `Active jobs: ${usage.usage.activeJobs.current}/${usage.usage.activeJobs.limit}`,
207
246
  );
208
- console.log(`Plan: ${usage.plan.tier}`);
247
+ console.log(`Plan: ${usage.plan}`); // e.g. "free"
209
248
 
210
249
  // Generate a unique slug for a new organization
211
250
  const { slug } = await client.organizations.generateSlug("My Company");
@@ -227,6 +266,14 @@ const webhook = await client.webhooks.create({
227
266
  secret: "webhook_secret_key",
228
267
  });
229
268
 
269
+ // Post dead-lettered jobs and workflow outcomes straight to a Slack channel
270
+ await client.webhooks.create({
271
+ name: "On-call",
272
+ url: "https://hooks.slack.com/services/...",
273
+ events: ["job.deadlettered", "workflow.failed"],
274
+ format: "slack", // "json" (default) | "slack" | "discord" | "teams"
275
+ });
276
+
230
277
  // Retry a failed delivery
231
278
  await client.webhooks.retryDelivery(webhookId, deliveryId);
232
279
 
@@ -235,6 +282,13 @@ await client.webhooks.retryDelivery(webhookId, deliveryId);
235
282
  const deliveries = await client.webhooks.getDeliveries(webhookId);
236
283
  ```
237
284
 
285
+ API 0.1.122 added the events `job.deadlettered`, `job.retried`, `workflow.completed` and
286
+ `workflow.failed`, and `format`, which sends each delivery as a chat message that a Slack, Discord
287
+ or Teams (legacy Office 365 connector) incoming-webhook URL accepts as it is.
288
+
289
+ The inbound side has a log too: `client.ingest.listDeliveries({ status, since, queueName })` lists
290
+ the accepted requests to your custom webhook URL (`GET /webhooks/deliveries`, no payloads).
291
+
238
292
  After 20 consecutive failed deliveries a webhook is disabled automatically (`enabled: false`,
239
293
  `lastStatus: "auto_disabled"`) and stops receiving events until you turn it back on:
240
294
 
@@ -282,7 +336,7 @@ Manage API keys programmatically:
282
336
  const apiKey = await client.apiKeys.create({
283
337
  name: "Production Worker",
284
338
  queues: ["emails", "notifications"],
285
- rateLimit: 1000,
339
+ rateLimit: 1000, // optional, requests per MINUTE for this key (can only lower the plan limit)
286
340
  });
287
341
 
288
342
  console.log(`Save this key: ${apiKey.key}`); // Only shown once!
@@ -337,10 +391,13 @@ All operations automatically enforce tier-based limits:
337
391
 
338
392
  | Tier | Active Jobs | Daily Jobs | Queues | Workers | Webhooks |
339
393
  | -------------- | ----------- | ---------- | --------- | --------- | --------- |
340
- | **Free** | 10 | 1,000 | 5 | 3 | 2 |
341
- | **Starter** | 100 | 100,000 | 25 | 25 | 10 |
394
+ | **Free** | 10 | 1,000 | 2 | 1 | 1 |
395
+ | **Starter** | 500 | 10,000 | 10 | 5 | 5 |
396
+ | **Pro** | 5,000 | 100,000 | 50 | 25 | 20 |
342
397
  | **Enterprise** | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited |
343
398
 
399
+ (Source: the backend's `config/plans.rs`; Free has no workflows, Starter 5, Pro 25.)
400
+
344
401
  **Limits are enforced on:**
345
402
 
346
403
  - ✅ HTTP job creation (`POST /jobs`, `POST /jobs/bulk`)
@@ -375,7 +432,23 @@ try {
375
432
  }
376
433
  ```
377
434
 
378
- (Per-second rate limiting also returns `429`, as a `RateLimitError` with the default `RATE_LIMIT_EXCEEDED` code.)
435
+ (Request-rate limiting also returns `429`, as a `RateLimitError` with the default `RATE_LIMIT_EXCEEDED` code. The plan limit is per organization in requests per second; an API key's own `rateLimit` is requests per minute.)
436
+
437
+ Since API 0.1.122, responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
438
+ (a Unix time in seconds) and `X-RateLimit-Scope` (`organization` or `api_key`), and a 429 adds
439
+ `Retry-After`. `client.getRateLimitInfo()` returns the values from the most recent response that had
440
+ them, and `RateLimitError.rateLimitInfo` those of a 429. The headers are optional: public routes and
441
+ servers without Redis do not send them.
442
+
443
+ ```typescript
444
+ await client.jobs.getStats();
445
+ const rate = client.getRateLimitInfo();
446
+ // { limit: 200, remaining: 199, reset: Date, scope: "organization" }
447
+ ```
448
+
449
+ A plan quota does not clear for hours, so `error.isQuota` is `true` for it and the SDK never retries it.
450
+ Rate-limit 429s are retried after `Retry-After`. No 429 counts toward the circuit breaker, so a burst
451
+ above the plan limit slows the client down instead of opening the breaker for every call.
379
452
 
380
453
  ## Error Handling
381
454
 
@@ -398,8 +471,9 @@ try {
398
471
  } else if (error instanceof AuthorizationError) {
399
472
  console.log(`Forbidden: ${error.message}`);
400
473
  } else if (error instanceof RateLimitError) {
401
- // 429: either a plan quota (code "QUOTA_EXCEEDED") or per-second rate limiting
402
- console.log(`Retry after ${error.getRetryAfter()} seconds`);
474
+ // 429: either a plan quota (error.isQuota, code "QUOTA_EXCEEDED") or per-second rate limiting
475
+ if (!error.isQuota)
476
+ console.log(`Retry after ${error.getRetryAfter()} seconds`);
403
477
  } else if (isSpooledError(error)) {
404
478
  console.log(`API error: ${error.code} - ${error.message}`);
405
479
  }
@@ -422,11 +496,12 @@ const grpcClient = new SpooledGrpcClient({
422
496
  useTls: true,
423
497
  });
424
498
 
425
- // Register worker
499
+ // Register worker (a stable workerId is reused across restarts)
426
500
  const { workerId } = await grpcClient.workers.register({
427
501
  queueName: "emails",
428
502
  hostname: "worker-1",
429
503
  maxConcurrency: 10,
504
+ workerId: "emails-worker-1",
430
505
  });
431
506
 
432
507
  // Enqueue job (enforces plan limits automatically)
@@ -444,18 +519,44 @@ const { jobs } = await grpcClient.queue.dequeue({
444
519
  leaseDurationSecs: 300,
445
520
  });
446
521
 
447
- // Process and complete. Echo leaseId when present so current servers can fence stale executions; legacy servers may omit it and then use worker-ID-only fencing.
522
+ // Process and settle. leaseId is required: the server rejects a complete/fail/renew
523
+ // without the lease token that dequeue returned. fail() retries by default (until the
524
+ // job's maxRetries run out); pass retry: false to dead-letter on purpose.
448
525
  for (const job of jobs) {
449
- await processJob(job);
450
- await grpcClient.queue.complete({
451
- jobId: job.id,
452
- workerId,
453
- leaseId: job.leaseId ?? undefined,
454
- result: { success: true },
455
- });
526
+ const leaseId = job.leaseId as string;
527
+ try {
528
+ await processJob(job);
529
+ await grpcClient.queue.complete({
530
+ jobId: job.id,
531
+ workerId,
532
+ leaseId,
533
+ result: { success: true },
534
+ });
535
+ } catch (error) {
536
+ await grpcClient.queue.fail({
537
+ jobId: job.id,
538
+ workerId,
539
+ leaseId,
540
+ error: error instanceof Error ? error.message : String(error),
541
+ });
542
+ }
456
543
  }
457
544
  ```
458
545
 
546
+ `fail` sends `retry: true` unless you pass `retry: false`; `false` is sent on the wire (proto field 4
547
+ has presence) and dead-letters the job at once, whatever its `maxRetries`.
548
+
549
+ Over gRPC `payload` and `result` are `google.protobuf.Struct`, so two things differ from REST: only a
550
+ JSON object travels (a job whose payload is an array, string, number or `null` arrives as an empty
551
+ object), and integers at or above 2^53 lose precision (send them as strings). Use REST for such jobs.
552
+
553
+ `dequeue` hands out up to `batchSize` jobs, each with a lease of `leaseDurationSecs` (5-3600 s; when
554
+ omitted the server default applies, 300 s on Spooled Cloud). For a job that can run longer than its
555
+ lease, pass a longer lease or renew it before it expires (`renewLease` with `jobId`, `workerId`,
556
+ `leaseId`, `extensionSecs`). A gRPC `StreamJobs` stream delivers **one job at a time**: the next
557
+ job is sent only after the previous one on that stream is completed, failed or released, so open
558
+ one stream per job slot you want to run in parallel.
559
+
459
560
  **gRPC Features:**
460
561
 
461
562
  - **~28x faster** than HTTP (with Redis cache: ~50ms vs 1400ms)