effect-mq 0.1.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/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/Job.d.ts +222 -0
- package/dist/Job.d.ts.map +1 -0
- package/dist/Job.js +218 -0
- package/dist/Job.js.map +1 -0
- package/dist/JobStore.d.ts +401 -0
- package/dist/JobStore.d.ts.map +1 -0
- package/dist/JobStore.js +89 -0
- package/dist/JobStore.js.map +1 -0
- package/dist/MemoryJobStore.d.ts +34 -0
- package/dist/MemoryJobStore.d.ts.map +1 -0
- package/dist/MemoryJobStore.js +381 -0
- package/dist/MemoryJobStore.js.map +1 -0
- package/dist/Worker.d.ts +127 -0
- package/dist/Worker.d.ts.map +1 -0
- package/dist/Worker.js +274 -0
- package/dist/Worker.js.map +1 -0
- package/dist/drizzle/DrizzleJobStore.d.ts +59 -0
- package/dist/drizzle/DrizzleJobStore.d.ts.map +1 -0
- package/dist/drizzle/DrizzleJobStore.js +426 -0
- package/dist/drizzle/DrizzleJobStore.js.map +1 -0
- package/dist/drizzle/index.d.ts +19 -0
- package/dist/drizzle/index.d.ts.map +1 -0
- package/dist/drizzle/index.js +19 -0
- package/dist/drizzle/index.js.map +1 -0
- package/dist/drizzle/schema.d.ts +464 -0
- package/dist/drizzle/schema.d.ts.map +1 -0
- package/dist/drizzle/schema.js +68 -0
- package/dist/drizzle/schema.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/conformance.d.ts +27 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +451 -0
- package/dist/testing/conformance.js.map +1 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +8 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +71 -0
- package/src/Job.ts +606 -0
- package/src/JobStore.ts +446 -0
- package/src/MemoryJobStore.ts +467 -0
- package/src/Worker.ts +514 -0
- package/src/drizzle/DrizzleJobStore.ts +599 -0
- package/src/drizzle/index.ts +20 -0
- package/src/drizzle/schema.ts +116 -0
- package/src/index.ts +33 -0
- package/src/testing/conformance.ts +654 -0
- package/src/testing/index.ts +7 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory `JobStore` built purely on Effect primitives.
|
|
3
|
+
*
|
|
4
|
+
* The reference driver: used for tests, local development and as the template
|
|
5
|
+
* for real storage adapters. Time comes from `Clock`, so everything is fully
|
|
6
|
+
* `TestClock`-compatible.
|
|
7
|
+
*
|
|
8
|
+
* Atomicity note: every operation reads the clock once and then mutates state
|
|
9
|
+
* in a single synchronous block, which is atomic on the JavaScript runtime.
|
|
10
|
+
*
|
|
11
|
+
* @since 0.1.0
|
|
12
|
+
*/
|
|
13
|
+
import { Clock, type Context, Deferred, Effect, Exit, Layer, Option } from "effect"
|
|
14
|
+
import {
|
|
15
|
+
type AttemptRecord,
|
|
16
|
+
type ClaimResult,
|
|
17
|
+
type EnqueueRequest,
|
|
18
|
+
JobId,
|
|
19
|
+
JobNotFoundError,
|
|
20
|
+
JobNotRetryableError,
|
|
21
|
+
type JobRecord,
|
|
22
|
+
type JobState,
|
|
23
|
+
JobStore,
|
|
24
|
+
type ListResult,
|
|
25
|
+
LockLostError,
|
|
26
|
+
type QueueName,
|
|
27
|
+
type Service
|
|
28
|
+
} from "./JobStore.ts"
|
|
29
|
+
|
|
30
|
+
interface MemJob {
|
|
31
|
+
readonly id: JobId
|
|
32
|
+
readonly name: string
|
|
33
|
+
readonly queue: QueueName
|
|
34
|
+
readonly payload: unknown
|
|
35
|
+
readonly metadata: Readonly<Record<string, string>>
|
|
36
|
+
state: JobState
|
|
37
|
+
readonly priority: number
|
|
38
|
+
readonly attemptsMax: number
|
|
39
|
+
attemptsMade: number
|
|
40
|
+
stalledCount: number
|
|
41
|
+
readonly backoff: JobRecord["backoff"]
|
|
42
|
+
readonly keep: JobRecord["keep"]
|
|
43
|
+
runAt: number
|
|
44
|
+
readonly enqueuedAt: number
|
|
45
|
+
processedAt: number | undefined
|
|
46
|
+
finishedAt: number | undefined
|
|
47
|
+
exit: unknown
|
|
48
|
+
failedReason: string | undefined
|
|
49
|
+
attempts: Array<AttemptRecord>
|
|
50
|
+
seq: number
|
|
51
|
+
lockToken: string | undefined
|
|
52
|
+
lockExpiresAt: number | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const snapshot = (job: MemJob): JobRecord => ({
|
|
56
|
+
id: job.id,
|
|
57
|
+
name: job.name,
|
|
58
|
+
queue: job.queue,
|
|
59
|
+
payload: job.payload,
|
|
60
|
+
metadata: job.metadata,
|
|
61
|
+
state: job.state,
|
|
62
|
+
priority: job.priority,
|
|
63
|
+
attemptsMax: job.attemptsMax,
|
|
64
|
+
attemptsMade: job.attemptsMade,
|
|
65
|
+
stalledCount: job.stalledCount,
|
|
66
|
+
backoff: job.backoff,
|
|
67
|
+
keep: job.keep,
|
|
68
|
+
runAt: job.runAt,
|
|
69
|
+
enqueuedAt: job.enqueuedAt,
|
|
70
|
+
processedAt: job.processedAt,
|
|
71
|
+
finishedAt: job.finishedAt,
|
|
72
|
+
exit: job.exit,
|
|
73
|
+
failedReason: job.failedReason
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const metadataMatches = (
|
|
77
|
+
record: Readonly<Record<string, string>>,
|
|
78
|
+
filter: Readonly<Record<string, string>>
|
|
79
|
+
): boolean => {
|
|
80
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
81
|
+
if (record[key] !== value) return false
|
|
82
|
+
}
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a fresh in-memory `JobStore` implementation.
|
|
88
|
+
*
|
|
89
|
+
* @since 0.1.0
|
|
90
|
+
*/
|
|
91
|
+
export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
92
|
+
const jobs = new Map<string, MemJob>()
|
|
93
|
+
let seq = 0
|
|
94
|
+
let idCounter = 0
|
|
95
|
+
let wakeVersion = 0
|
|
96
|
+
let wake = Deferred.makeUnsafe<void>()
|
|
97
|
+
|
|
98
|
+
// Synchronous on purpose: it is called inside the same synchronous block as
|
|
99
|
+
// the state mutation, so no effect-op boundary (where an interrupt could
|
|
100
|
+
// land) can separate a mutation from its wake-up signal.
|
|
101
|
+
const signalWake = () => {
|
|
102
|
+
wakeVersion += 1
|
|
103
|
+
const current = wake
|
|
104
|
+
wake = Deferred.makeUnsafe<void>()
|
|
105
|
+
Deferred.doneUnsafe(current, Exit.succeed<void>(void 0))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const promoteDue = (now: number) => {
|
|
109
|
+
for (const job of jobs.values()) {
|
|
110
|
+
if (job.state === "delayed" && job.runAt <= now) {
|
|
111
|
+
job.state = "waiting"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const clearLock = (job: MemJob) => {
|
|
117
|
+
job.lockToken = undefined
|
|
118
|
+
job.lockExpiresAt = undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const recordAttempt = (
|
|
122
|
+
job: MemJob,
|
|
123
|
+
outcome: AttemptRecord["outcome"],
|
|
124
|
+
finishedAt: number,
|
|
125
|
+
exit: AttemptRecord["exit"]
|
|
126
|
+
) => {
|
|
127
|
+
job.attempts.push({
|
|
128
|
+
attempt: job.attempts.length + 1,
|
|
129
|
+
startedAt: job.processedAt,
|
|
130
|
+
finishedAt,
|
|
131
|
+
outcome,
|
|
132
|
+
exit
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Terminal retention: keep at most `count` and drop older than `ageMs`
|
|
137
|
+
// among terminal jobs sharing this job's name + state.
|
|
138
|
+
const applyKeep = (job: MemJob, now: number) => {
|
|
139
|
+
if (job.keep === undefined) return
|
|
140
|
+
const peers = Array.from(jobs.values())
|
|
141
|
+
.filter((peer) => peer.name === job.name && peer.state === job.state)
|
|
142
|
+
.toSorted((a, b) => ((b.finishedAt ?? 0) - (a.finishedAt ?? 0)) || (b.seq - a.seq))
|
|
143
|
+
const remove = new Set<string>()
|
|
144
|
+
if (job.keep.ageMs !== undefined) {
|
|
145
|
+
for (const peer of peers) {
|
|
146
|
+
if (peer.finishedAt !== undefined && peer.finishedAt <= now - job.keep.ageMs) {
|
|
147
|
+
remove.add(peer.id)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (job.keep.count !== undefined) {
|
|
152
|
+
for (const peer of peers.slice(Math.max(0, job.keep.count))) {
|
|
153
|
+
remove.add(peer.id)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const id of remove) {
|
|
157
|
+
jobs.delete(id)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return JobStore.of({
|
|
162
|
+
enqueue: (request: EnqueueRequest) =>
|
|
163
|
+
Effect.gen(function*() {
|
|
164
|
+
const now = yield* Clock.currentTimeMillis
|
|
165
|
+
let id = request.id
|
|
166
|
+
if (id === undefined) {
|
|
167
|
+
// Store-assigned ids must never collide with user-supplied ones.
|
|
168
|
+
do {
|
|
169
|
+
id = JobId(`j-${++idCounter}`)
|
|
170
|
+
} while (jobs.has(id))
|
|
171
|
+
} else if (jobs.has(id)) {
|
|
172
|
+
return { id, duplicate: true }
|
|
173
|
+
}
|
|
174
|
+
jobs.set(id, {
|
|
175
|
+
id,
|
|
176
|
+
name: request.name,
|
|
177
|
+
queue: request.queue,
|
|
178
|
+
payload: request.payload,
|
|
179
|
+
metadata: request.metadata,
|
|
180
|
+
state: request.delayMs > 0 ? "delayed" : "waiting",
|
|
181
|
+
priority: request.priority,
|
|
182
|
+
attemptsMax: request.attemptsMax,
|
|
183
|
+
attemptsMade: 0,
|
|
184
|
+
stalledCount: 0,
|
|
185
|
+
backoff: request.backoff,
|
|
186
|
+
keep: request.keep,
|
|
187
|
+
runAt: now + Math.max(0, request.delayMs),
|
|
188
|
+
enqueuedAt: now,
|
|
189
|
+
processedAt: undefined,
|
|
190
|
+
finishedAt: undefined,
|
|
191
|
+
exit: undefined,
|
|
192
|
+
failedReason: undefined,
|
|
193
|
+
attempts: [],
|
|
194
|
+
seq: ++seq,
|
|
195
|
+
lockToken: undefined,
|
|
196
|
+
lockExpiresAt: undefined
|
|
197
|
+
})
|
|
198
|
+
signalWake()
|
|
199
|
+
return { id, duplicate: false }
|
|
200
|
+
}),
|
|
201
|
+
|
|
202
|
+
claim: (options) =>
|
|
203
|
+
Effect.gen(function*() {
|
|
204
|
+
const now = yield* Clock.currentTimeMillis
|
|
205
|
+
promoteDue(now)
|
|
206
|
+
const names = new Set(options.names)
|
|
207
|
+
let best: MemJob | undefined
|
|
208
|
+
let nextRunAt: number | undefined
|
|
209
|
+
for (const job of jobs.values()) {
|
|
210
|
+
if (job.queue !== options.queue || !names.has(job.name)) continue
|
|
211
|
+
if (job.state === "waiting") {
|
|
212
|
+
if (
|
|
213
|
+
best === undefined ||
|
|
214
|
+
job.priority > best.priority ||
|
|
215
|
+
(job.priority === best.priority && job.seq < best.seq)
|
|
216
|
+
) {
|
|
217
|
+
best = job
|
|
218
|
+
}
|
|
219
|
+
} else if (job.state === "delayed") {
|
|
220
|
+
if (nextRunAt === undefined || job.runAt < nextRunAt) {
|
|
221
|
+
nextRunAt = job.runAt
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (best === undefined) {
|
|
226
|
+
const empty: ClaimResult = { _tag: "Empty", nextRunAt, wakeToken: wakeVersion }
|
|
227
|
+
return empty
|
|
228
|
+
}
|
|
229
|
+
best.state = "active"
|
|
230
|
+
best.lockToken = options.token
|
|
231
|
+
best.lockExpiresAt = now + options.lockDurationMs
|
|
232
|
+
best.processedAt = now
|
|
233
|
+
const claimed: ClaimResult = { _tag: "Claimed", job: snapshot(best) }
|
|
234
|
+
return claimed
|
|
235
|
+
}),
|
|
236
|
+
|
|
237
|
+
ack: (id, token, outcome) =>
|
|
238
|
+
Effect.gen(function*() {
|
|
239
|
+
const now = yield* Clock.currentTimeMillis
|
|
240
|
+
const job = jobs.get(id)
|
|
241
|
+
if (job === undefined) {
|
|
242
|
+
return yield* new JobNotFoundError({ jobId: id })
|
|
243
|
+
}
|
|
244
|
+
if (job.state !== "active" || job.lockToken !== token) {
|
|
245
|
+
return yield* new LockLostError({ jobId: id })
|
|
246
|
+
}
|
|
247
|
+
clearLock(job)
|
|
248
|
+
job.attemptsMade += 1
|
|
249
|
+
switch (outcome._tag) {
|
|
250
|
+
case "Complete": {
|
|
251
|
+
job.state = "completed"
|
|
252
|
+
job.exit = outcome.exit
|
|
253
|
+
job.finishedAt = now
|
|
254
|
+
recordAttempt(job, "completed", now, outcome.exit)
|
|
255
|
+
applyKeep(job, now)
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
case "Fail": {
|
|
259
|
+
job.state = "failed"
|
|
260
|
+
job.exit = outcome.exit
|
|
261
|
+
job.finishedAt = now
|
|
262
|
+
recordAttempt(job, "failed", now, outcome.exit)
|
|
263
|
+
applyKeep(job, now)
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
case "Retry": {
|
|
267
|
+
recordAttempt(job, "retried", now, outcome.exit)
|
|
268
|
+
job.runAt = now + Math.max(0, outcome.delayMs)
|
|
269
|
+
job.state = outcome.delayMs > 0 ? "delayed" : "waiting"
|
|
270
|
+
job.seq = ++seq
|
|
271
|
+
signalWake()
|
|
272
|
+
break
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}),
|
|
276
|
+
|
|
277
|
+
release: (id, token) =>
|
|
278
|
+
Effect.gen(function*() {
|
|
279
|
+
const job = jobs.get(id)
|
|
280
|
+
if (job === undefined) {
|
|
281
|
+
return yield* new JobNotFoundError({ jobId: id })
|
|
282
|
+
}
|
|
283
|
+
if (job.state !== "active" || job.lockToken !== token) {
|
|
284
|
+
return yield* new LockLostError({ jobId: id })
|
|
285
|
+
}
|
|
286
|
+
clearLock(job)
|
|
287
|
+
job.state = "waiting"
|
|
288
|
+
signalWake()
|
|
289
|
+
}),
|
|
290
|
+
|
|
291
|
+
extendLocks: (locks, durationMs) =>
|
|
292
|
+
Effect.gen(function*() {
|
|
293
|
+
const now = yield* Clock.currentTimeMillis
|
|
294
|
+
const lost: Array<JobId> = []
|
|
295
|
+
for (const lock of locks) {
|
|
296
|
+
const job = jobs.get(lock.id)
|
|
297
|
+
if (
|
|
298
|
+
job !== undefined &&
|
|
299
|
+
job.state === "active" &&
|
|
300
|
+
job.lockToken === lock.token
|
|
301
|
+
) {
|
|
302
|
+
job.lockExpiresAt = now + durationMs
|
|
303
|
+
} else {
|
|
304
|
+
lost.push(lock.id)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return lost
|
|
308
|
+
}),
|
|
309
|
+
|
|
310
|
+
recoverStalled: (options) =>
|
|
311
|
+
Effect.gen(function*() {
|
|
312
|
+
const now = yield* Clock.currentTimeMillis
|
|
313
|
+
const recovered: Array<{ id: JobId; failed: boolean }> = []
|
|
314
|
+
for (const job of jobs.values()) {
|
|
315
|
+
if (
|
|
316
|
+
job.state !== "active" ||
|
|
317
|
+
job.lockExpiresAt === undefined ||
|
|
318
|
+
job.lockExpiresAt > now
|
|
319
|
+
) {
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
clearLock(job)
|
|
323
|
+
job.stalledCount += 1
|
|
324
|
+
recordAttempt(job, "stalled", now, undefined)
|
|
325
|
+
if (job.stalledCount > options.maxStalledCount) {
|
|
326
|
+
job.state = "failed"
|
|
327
|
+
job.finishedAt = now
|
|
328
|
+
job.failedReason = "job stalled more than allowable limit"
|
|
329
|
+
recovered.push({ id: job.id, failed: true })
|
|
330
|
+
} else {
|
|
331
|
+
job.state = "waiting"
|
|
332
|
+
recovered.push({ id: job.id, failed: false })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (recovered.some((entry) => !entry.failed)) {
|
|
336
|
+
signalWake()
|
|
337
|
+
}
|
|
338
|
+
return recovered
|
|
339
|
+
}),
|
|
340
|
+
|
|
341
|
+
awaitWake: (_queues, wakeToken) =>
|
|
342
|
+
Effect.suspend(() => {
|
|
343
|
+
if (wakeVersion > wakeToken) return Effect.void
|
|
344
|
+
return Deferred.await(wake)
|
|
345
|
+
}),
|
|
346
|
+
|
|
347
|
+
getJob: (id) =>
|
|
348
|
+
Effect.sync(() => {
|
|
349
|
+
const job = jobs.get(id)
|
|
350
|
+
return job === undefined ? Option.none() : Option.some(snapshot(job))
|
|
351
|
+
}),
|
|
352
|
+
|
|
353
|
+
getAttempts: (id) =>
|
|
354
|
+
Effect.sync(() => {
|
|
355
|
+
const job = jobs.get(id)
|
|
356
|
+
return job === undefined ? [] : [...job.attempts]
|
|
357
|
+
}),
|
|
358
|
+
|
|
359
|
+
list: (options) =>
|
|
360
|
+
Effect.sync(() => {
|
|
361
|
+
const limit = Math.max(1, options.limit ?? 50)
|
|
362
|
+
const states = options.states === undefined ? undefined : new Set(options.states)
|
|
363
|
+
// Newest first, stable across retries: (enqueuedAt desc, id desc).
|
|
364
|
+
let cursor: { readonly at: number; readonly id: string } | undefined
|
|
365
|
+
if (options.cursor !== undefined) {
|
|
366
|
+
const split = options.cursor.indexOf(":")
|
|
367
|
+
cursor = {
|
|
368
|
+
at: Number(options.cursor.slice(0, split)),
|
|
369
|
+
id: options.cursor.slice(split + 1)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const matches = Array.from(jobs.values())
|
|
373
|
+
.filter((job) =>
|
|
374
|
+
(options.queue === undefined || job.queue === options.queue) &&
|
|
375
|
+
(options.name === undefined || job.name === options.name) &&
|
|
376
|
+
(states === undefined || states.has(job.state)) &&
|
|
377
|
+
(options.metadata === undefined || metadataMatches(job.metadata, options.metadata))
|
|
378
|
+
)
|
|
379
|
+
.toSorted((a, b) =>
|
|
380
|
+
b.enqueuedAt !== a.enqueuedAt
|
|
381
|
+
? b.enqueuedAt - a.enqueuedAt
|
|
382
|
+
: b.id < a.id
|
|
383
|
+
? -1
|
|
384
|
+
: b.id > a.id
|
|
385
|
+
? 1
|
|
386
|
+
: 0
|
|
387
|
+
)
|
|
388
|
+
.filter((job) =>
|
|
389
|
+
cursor === undefined ||
|
|
390
|
+
job.enqueuedAt < cursor.at ||
|
|
391
|
+
(job.enqueuedAt === cursor.at && job.id < cursor.id)
|
|
392
|
+
)
|
|
393
|
+
const items = matches.slice(0, limit).map(snapshot)
|
|
394
|
+
const last = items[items.length - 1]
|
|
395
|
+
const result: ListResult = {
|
|
396
|
+
items,
|
|
397
|
+
cursor: matches.length > limit && last !== undefined
|
|
398
|
+
? `${last.enqueuedAt}:${last.id}`
|
|
399
|
+
: undefined
|
|
400
|
+
}
|
|
401
|
+
return result
|
|
402
|
+
}),
|
|
403
|
+
|
|
404
|
+
retry: (id) =>
|
|
405
|
+
Effect.gen(function*() {
|
|
406
|
+
const job = jobs.get(id)
|
|
407
|
+
if (job === undefined) {
|
|
408
|
+
return yield* new JobNotFoundError({ jobId: id })
|
|
409
|
+
}
|
|
410
|
+
if (job.state !== "failed") {
|
|
411
|
+
return yield* new JobNotRetryableError({ jobId: id, state: job.state })
|
|
412
|
+
}
|
|
413
|
+
const now = yield* Clock.currentTimeMillis
|
|
414
|
+
job.state = "waiting"
|
|
415
|
+
job.attemptsMade = 0
|
|
416
|
+
job.stalledCount = 0
|
|
417
|
+
job.exit = undefined
|
|
418
|
+
job.failedReason = undefined
|
|
419
|
+
job.finishedAt = undefined
|
|
420
|
+
job.processedAt = undefined
|
|
421
|
+
job.runAt = now
|
|
422
|
+
job.seq = ++seq
|
|
423
|
+
signalWake()
|
|
424
|
+
}),
|
|
425
|
+
|
|
426
|
+
counts: (queue) =>
|
|
427
|
+
Effect.sync(() => {
|
|
428
|
+
const counts = {
|
|
429
|
+
waiting: 0,
|
|
430
|
+
delayed: 0,
|
|
431
|
+
active: 0,
|
|
432
|
+
completed: 0,
|
|
433
|
+
failed: 0
|
|
434
|
+
} satisfies Record<JobState, number>
|
|
435
|
+
for (const job of jobs.values()) {
|
|
436
|
+
if (queue !== undefined && job.queue !== queue) continue
|
|
437
|
+
counts[job.state] += 1
|
|
438
|
+
}
|
|
439
|
+
return counts
|
|
440
|
+
}),
|
|
441
|
+
|
|
442
|
+
remove: (id) =>
|
|
443
|
+
Effect.sync(() => {
|
|
444
|
+
const job = jobs.get(id)
|
|
445
|
+
if (job === undefined || job.state === "active") return false
|
|
446
|
+
jobs.delete(id)
|
|
447
|
+
return true
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A fresh in-memory `JobStore` layer. Pass a named store key to provide a
|
|
454
|
+
* `JobStore.named(...)` slot instead of the default.
|
|
455
|
+
*
|
|
456
|
+
* @since 0.1.0
|
|
457
|
+
*/
|
|
458
|
+
export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, make)
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* An in-memory layer for a specific store key.
|
|
462
|
+
*
|
|
463
|
+
* @since 0.1.0
|
|
464
|
+
*/
|
|
465
|
+
export const layerFor = <Id>(
|
|
466
|
+
store: Context.Key<Id, Service>
|
|
467
|
+
): Layer.Layer<Id> => Layer.effect(store, make)
|