@strav/queue 0.3.29 → 0.3.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/queue",
3
- "version": "0.3.29",
3
+ "version": "0.3.32",
4
4
  "type": "module",
5
5
  "description": "Background job processing and task scheduling for the Strav framework",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "./providers/*": "./src/providers/*.ts"
29
29
  },
30
30
  "peerDependencies": {
31
- "@strav/kernel": "0.3.29",
32
- "@strav/database": "0.3.29"
31
+ "@strav/kernel": "0.3.32",
32
+ "@strav/database": "0.3.32"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "bun test tests/",
@@ -26,6 +26,29 @@ export interface JobMeta {
26
26
  job: string
27
27
  attempts: number
28
28
  maxAttempts: number
29
+ /**
30
+ * Report progress for a long-running job. `value` is `0..1`. The reported
31
+ * value is persisted to the job row so external consumers can poll via
32
+ * {@link Queue.progressOf}, and a `queue:progress` event is emitted for
33
+ * live consumers (e.g. SSE).
34
+ *
35
+ * Returns immediately after persisting; safe to call from a tight loop
36
+ * but throttle to avoid hammering the database (e.g. every N rows or
37
+ * every 1 s).
38
+ */
39
+ progress: (value: number, message?: string) => Promise<void>
40
+ }
41
+
42
+ /** Snapshot of a job's current progress, returned by {@link Queue.progressOf}. */
43
+ export interface JobProgress {
44
+ /** Job id. */
45
+ id: number
46
+ /** 0..1, last reported by the handler. */
47
+ value: number
48
+ /** Optional human-readable message attached to the last update. */
49
+ message: string | null
50
+ /** Current attempt count. */
51
+ attempts: number
29
52
  }
30
53
 
31
54
  /** A raw job row from the _strav_jobs table. */
@@ -116,10 +139,23 @@ export default class Queue {
116
139
  "timeout" INT NOT NULL DEFAULT 60000,
117
140
  "available_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
118
141
  "reserved_at" TIMESTAMPTZ,
119
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
142
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
143
+ "progress" NUMERIC NOT NULL DEFAULT 0,
144
+ "progress_message" TEXT
120
145
  )
121
146
  `
122
147
 
148
+ // Additive migrations for progress columns — for tables that existed
149
+ // before progress reporting was introduced.
150
+ await sql`
151
+ ALTER TABLE "_strav_jobs"
152
+ ADD COLUMN IF NOT EXISTS "progress" NUMERIC NOT NULL DEFAULT 0
153
+ `
154
+ await sql`
155
+ ALTER TABLE "_strav_jobs"
156
+ ADD COLUMN IF NOT EXISTS "progress_message" TEXT
157
+ `
158
+
123
159
  await sql`
124
160
  CREATE INDEX IF NOT EXISTS "idx_strav_jobs_queue_available"
125
161
  ON "_strav_jobs" ("queue", "available_at")
@@ -168,6 +204,57 @@ export default class Queue {
168
204
  return id
169
205
  }
170
206
 
207
+ /**
208
+ * Persist progress for an in-flight job and emit a `queue:progress` event.
209
+ * Called by the `JobMeta.progress` callback that workers hand to handlers,
210
+ * but exposed statically so other code (e.g. retry replay tools) can update
211
+ * progress directly. `value` is clamped to `[0, 1]`.
212
+ */
213
+ static async reportProgress(
214
+ id: number,
215
+ value: number,
216
+ message?: string
217
+ ): Promise<void> {
218
+ const sql = Queue.db.sql
219
+ const clamped = Math.max(0, Math.min(1, value))
220
+ const msg = message ?? null
221
+ await sql`
222
+ UPDATE "_strav_jobs"
223
+ SET "progress" = ${clamped}, "progress_message" = ${msg}
224
+ WHERE "id" = ${id}
225
+ `
226
+ if (Emitter.listenerCount('queue:progress') > 0) {
227
+ Emitter.emit('queue:progress', {
228
+ id,
229
+ value: clamped,
230
+ message: msg,
231
+ }).catch(() => {})
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Read the latest progress snapshot for a job. Returns `null` once the
237
+ * job has completed (the row is deleted on success) or if the id is
238
+ * unknown.
239
+ */
240
+ static async progressOf(id: number): Promise<JobProgress | null> {
241
+ const sql = Queue.db.sql
242
+ const rows = await sql`
243
+ SELECT "id", "progress", "progress_message", "attempts"
244
+ FROM "_strav_jobs"
245
+ WHERE "id" = ${id}
246
+ LIMIT 1
247
+ `
248
+ if (rows.length === 0) return null
249
+ const row = rows[0] as Record<string, unknown>
250
+ return {
251
+ id: Number(row.id),
252
+ value: Number(row.progress ?? 0),
253
+ message: (row.progress_message as string | null) ?? null,
254
+ attempts: Number(row.attempts ?? 0),
255
+ }
256
+ }
257
+
171
258
  /**
172
259
  * Create a listener function suitable for Emitter.on().
173
260
  * When the event fires, the payload is pushed onto the queue.
@@ -122,6 +122,7 @@ export default class Worker {
122
122
  job: job.job,
123
123
  attempts: job.attempts,
124
124
  maxAttempts: job.maxAttempts,
125
+ progress: (value: number, message?: string) => Queue.reportProgress(job.id, value, message),
125
126
  }
126
127
 
127
128
  const start = performance.now()