effect-mq 0.3.2 → 0.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.
Files changed (42) hide show
  1. package/README.md +102 -14
  2. package/dist/Job.d.ts +131 -9
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +81 -5
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +63 -2
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js.map +1 -1
  9. package/dist/MemoryJobStore.d.ts.map +1 -1
  10. package/dist/MemoryJobStore.js +159 -119
  11. package/dist/MemoryJobStore.js.map +1 -1
  12. package/dist/Worker.d.ts +18 -0
  13. package/dist/Worker.d.ts.map +1 -1
  14. package/dist/Worker.js +37 -9
  15. package/dist/Worker.js.map +1 -1
  16. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
  18. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  19. package/dist/drizzle-postgres/schema.d.ts +2 -0
  20. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  21. package/dist/drizzle-postgres/schema.js +1 -0
  22. package/dist/drizzle-postgres/schema.js.map +1 -1
  23. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  24. package/dist/redis/RedisJobStore.js +173 -36
  25. package/dist/redis/RedisJobStore.js.map +1 -1
  26. package/dist/redis/scripts.d.ts +24 -1
  27. package/dist/redis/scripts.d.ts.map +1 -1
  28. package/dist/redis/scripts.js +126 -21
  29. package/dist/redis/scripts.js.map +1 -1
  30. package/dist/testing/conformance.d.ts.map +1 -1
  31. package/dist/testing/conformance.js +266 -0
  32. package/dist/testing/conformance.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Job.ts +268 -13
  35. package/src/JobStore.ts +77 -2
  36. package/src/MemoryJobStore.ts +102 -53
  37. package/src/Worker.ts +61 -9
  38. package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
  39. package/src/drizzle-postgres/schema.ts +2 -0
  40. package/src/redis/RedisJobStore.ts +236 -47
  41. package/src/redis/scripts.ts +153 -21
  42. package/src/testing/conformance.ts +350 -0
package/src/Job.ts CHANGED
@@ -30,11 +30,12 @@
30
30
  *
31
31
  * @since 0.1.0
32
32
  */
33
- import { Clock, type Context, Duration, Effect, type Exit, Layer, Metric, Option, Predicate, Schedule, Schema } from "effect"
33
+ import { Clock, type Context, DateTime, Duration, Effect, type Exit, Layer, Metric, Option, Predicate, Schedule, Schema } from "effect"
34
34
  import {
35
35
  type BackoffPolicy,
36
36
  type DedupePolicy,
37
37
  type KeepStatePolicy,
38
+ type TraceContext,
38
39
  JobCancelledError,
39
40
  JobId,
40
41
  type JobNotCancellableError,
@@ -130,6 +131,11 @@ export interface JobOptions {
130
131
  readonly priority?: number | undefined
131
132
  /** Total attempts including the first run. Default 1 (no retries). */
132
133
  readonly attempts?: number | undefined
134
+ /**
135
+ * Delay between retry attempts:
136
+ * `{ type: "fixed" | "exponential", delay, factor? }`. Default: retries
137
+ * are immediate.
138
+ */
133
139
  readonly backoff?: BackoffInput | undefined
134
140
  /** Retention for terminal records. Default: keep forever. */
135
141
  readonly keep?: KeepInput | undefined
@@ -158,9 +164,28 @@ export interface JobOptions {
158
164
  * @since 0.3.0
159
165
  */
160
166
  export type DedupeInput = string | {
167
+ /**
168
+ * The dedup key, scoped to this job's name (e.g. an employee id). Never
169
+ * changes the job id. Must be non-empty.
170
+ */
161
171
  readonly key: string
172
+ /**
173
+ * Throttle window: at most one job per key per window, even after the
174
+ * keyed job completes. Without `ttl`, dedup lasts while the keyed job is
175
+ * pending (waiting/delayed/active).
176
+ */
162
177
  readonly ttl?: Duration.Input | undefined
178
+ /**
179
+ * Debounce (requires `ttl`): every deduplicated enqueue pushes the window
180
+ * out again.
181
+ */
163
182
  readonly extend?: boolean | undefined
183
+ /**
184
+ * Latest-wins while the keyed job is still delayed: the new enqueue's
185
+ * payload/metadata/priority/attempts/backoff/keep/timeout/delay replace
186
+ * the existing job's (same id, ledger preserved). A landed replace
187
+ * re-arms the `ttl` window. In any other state, normal dedup applies.
188
+ */
164
189
  readonly replace?: boolean | undefined
165
190
  }
166
191
 
@@ -181,10 +206,51 @@ const normalizeDedupe = (input: DedupeInput | undefined): DedupePolicy | undefin
181
206
  }
182
207
  }
183
208
 
209
+ /**
210
+ * When the job becomes runnable: a relative `delay` OR an absolute `at`
211
+ * (any `DateTime.Input` — a `DateTime` from `DateTime.makeZonedUnsafe` for
212
+ * wall-clock-in-timezone instants, a `Date`, ISO string, epoch millis, or
213
+ * date parts). Setting both is a compile error; an `at` in the past runs
214
+ * immediately.
215
+ *
216
+ * @since 0.4.0
217
+ */
218
+ export type RunTimeInput =
219
+ | {
220
+ /**
221
+ * Run this long after enqueue (relative). Any `Duration.Input`:
222
+ * `"5 seconds"`, `Duration.minutes(10)`, millis, ...
223
+ *
224
+ * Mutually exclusive with `at` (setting both is a compile error).
225
+ */
226
+ readonly delay?: Duration.Input | undefined
227
+ /** Set `delay` for relative times, or `at` (alone) for absolute ones. */
228
+ readonly at?: undefined
229
+ }
230
+ | {
231
+ /**
232
+ * Run at an absolute instant (any `DateTime.Input`) — no duration math:
233
+ *
234
+ * ```ts
235
+ * at: DateTime.makeZonedUnsafe(
236
+ * { year: 2026, month: 8, day: 24, hours: 9 },
237
+ * { timeZone: "America/New_York", adjustForTimeZone: true }
238
+ * )
239
+ * // also: a Date, ISO string, epoch millis, or { year, month, ... } parts
240
+ * ```
241
+ *
242
+ * An `at` in the past runs immediately. Mutually exclusive with `delay`
243
+ * (setting both is a compile error).
244
+ */
245
+ readonly at?: DateTime.DateTime.Input | undefined
246
+ /** Set `at` for absolute times, or `delay` (alone) for relative ones. */
247
+ readonly delay?: undefined
248
+ }
249
+
184
250
  /**
185
251
  * @since 0.1.0
186
252
  */
187
- export interface EnqueueOptions extends JobOptions {
253
+ export type EnqueueOptions = Omit<JobOptions, "delay"> & RunTimeInput & {
188
254
  /**
189
255
  * Explicit job id. Enqueueing an id that already exists is a no-op that
190
256
  * returns the existing id (idempotency). Overrides the definition's
@@ -199,6 +265,27 @@ export interface EnqueueOptions extends JobOptions {
199
265
  readonly dedupe?: DedupeInput | undefined
200
266
  }
201
267
 
268
+ /**
269
+ * Options for `enqueueMany` — `EnqueueOptions` minus the per-job fields:
270
+ * `jobId` (a shared id would make every item after the first a duplicate)
271
+ * and `dedupe` (a shared key would collapse the batch into one job; per-item
272
+ * dedup still runs via the definition's `dedupe` callback).
273
+ *
274
+ * Built from parts rather than `Omit<EnqueueOptions, ...>`: a mapped type
275
+ * over the `delay`/`at` union would flatten it and lose their exclusivity.
276
+ *
277
+ * @since 0.4.0
278
+ */
279
+ export type EnqueueManyOptions = Omit<JobOptions, "delay"> & RunTimeInput & {
280
+ /** Send every item to a different queue than the definition's. */
281
+ readonly queue?: string | undefined
282
+ /**
283
+ * Queryable business context, merged over each item's definition-derived
284
+ * `metadata` (the same overrides apply to every item).
285
+ */
286
+ readonly metadata?: Readonly<Record<string, string>> | undefined
287
+ }
288
+
202
289
  interface ResolvedDefaults {
203
290
  readonly delayMs: number
204
291
  readonly priority: number
@@ -216,16 +303,32 @@ interface ResolvedDefaults {
216
303
  * @since 0.2.0
217
304
  */
218
305
  export interface ScheduleOptions<PayloadInput> {
306
+ /**
307
+ * Cron expression (5-field, e.g. `"0 9 * * 1"` = 9:00 every Monday).
308
+ * First fires at the next matching occurrence. Exactly one of `cron` or
309
+ * `every` must be set.
310
+ */
219
311
  readonly cron?: string | undefined
312
+ /** IANA time zone for `cron` (e.g. `"America/New_York"`). Default UTC. */
220
313
  readonly tz?: string | undefined
314
+ /**
315
+ * Fixed interval; first fires one interval from now and stays on that
316
+ * grid. Exactly one of `cron` or `every` must be set.
317
+ */
221
318
  readonly every?: Duration.Input | undefined
319
+ /** The payload every occurrence is enqueued with. */
222
320
  readonly payload: PayloadInput
223
321
  /** Queryable business context, merged over the definition's `metadata`. */
224
322
  readonly metadata?: Readonly<Record<string, string>> | undefined
323
+ /** Priority for each occurrence. Higher runs first; default 0. */
225
324
  readonly priority?: number | undefined
325
+ /** Attempt budget for each occurrence. Default 1. */
226
326
  readonly attempts?: number | undefined
327
+ /** Retry backoff for each occurrence. */
227
328
  readonly backoff?: BackoffInput | undefined
329
+ /** Retention for each occurrence's terminal record. */
228
330
  readonly keep?: KeepInput | undefined
331
+ /** Per-run execution time limit for each occurrence. */
229
332
  readonly timeout?: Duration.Input | undefined
230
333
  }
231
334
 
@@ -307,6 +410,26 @@ export interface Job<
307
410
  options?: EnqueueOptions | undefined
308
411
  ) => Effect.Effect<JobId, never, StoreId | Payload["EncodingServices"]>
309
412
 
413
+ /**
414
+ * Queue many jobs in bulk — one store round trip per chunk of plain
415
+ * items; items whose definition derives a `dedupe` key fall back to
416
+ * individual enqueues, in order. Returns ids aligned with the payloads.
417
+ * Per-item semantics match `enqueue`: `idempotencyKey`, `dedupe`, and
418
+ * `metadata` callbacks run for each payload, and duplicates are silent
419
+ * no-ops returning the existing id. Options apply to every item
420
+ * (`jobId`/`dedupe` are excluded — a shared id or dedup key would
421
+ * collapse the batch into one job).
422
+ *
423
+ * The batch is not transactional: a store failure mid-batch may leave a
424
+ * subset (not necessarily a prefix) enqueued. Safe under at-least-once —
425
+ * re-running the batch skips already-inserted items when ids are
426
+ * deterministic (`idempotencyKey`); store-assigned ids may re-insert.
427
+ */
428
+ readonly enqueueMany: (
429
+ payloads: ReadonlyArray<Payload["~type.make.in"]>,
430
+ options?: EnqueueManyOptions | undefined
431
+ ) => Effect.Effect<ReadonlyArray<JobId>, never, StoreId | Payload["EncodingServices"]>
432
+
310
433
  /** Read the current status of a previously enqueued job. */
311
434
  readonly poll: (
312
435
  jobId: JobId
@@ -368,18 +491,23 @@ export interface Job<
368
491
  jobId: JobId
369
492
  ) => Effect.Effect<void, JobNotFoundError | JobNotCancellableError, StoreId>
370
493
 
494
+ /**
495
+ * Cancel whatever pending job holds this dedup key (see `DedupeInput`).
496
+ * Idempotent: returns false when nothing pending holds the key.
497
+ */
498
+ readonly cancelByKey: (key: string) => Effect.Effect<boolean, never, StoreId>
499
+
371
500
  /** Run a delayed job now. */
372
501
  readonly promote: (
373
502
  jobId: JobId
374
503
  ) => Effect.Effect<void, JobNotFoundError | JobNotPromotableError, StoreId>
375
504
 
376
505
  /**
377
- * Create or replace a durable repeatable schedule for this job. Ticks
378
- * enqueue with a slot-deterministic id, so schedules are exactly-once per
379
- * occurrence across all workers (assuming history retention windows
380
- * comfortably exceed the sweep interval a pruned tick job cannot dedup a
381
- * pathologically stale sweeper). Missed occurrences (downtime) collapse
382
- * into a single catch-up run.
506
+ * Create or replace a durable repeatable schedule for this job. Each
507
+ * occurrence is claimed and enqueued in one atomic store op, so ticks are
508
+ * exactly-once per occurrence across all workers regardless of history
509
+ * retention. Missed occurrences (downtime) collapse into a single
510
+ * catch-up run.
383
511
  *
384
512
  * Re-registering with an *unchanged* cadence (same `cron`/`tz`/`every`) is
385
513
  * a no-op for the next occurrence — deploy-time re-registration neither
@@ -490,9 +618,33 @@ const Proto = {
490
618
  options?.dedupe ?? this.dedupe?.(payload)
491
619
  )
492
620
  const queue = options?.queue !== undefined ? QueueName(options.queue) : this.queue
493
- return Schema.encodeEffect(this.payloadJsonSchema)(payload).pipe(
621
+ if (options?.at !== undefined && options.delay !== undefined) {
622
+ // Unrepresentable in TypeScript; guard untyped callers anyway.
623
+ throw new Error("effect-mq: set either `delay` or `at`, not both")
624
+ }
625
+ const delayMs = options?.at !== undefined
626
+ ? Effect.map(
627
+ Clock.currentTimeMillis,
628
+ (now) => Math.max(0, DateTime.toEpochMillis(DateTime.makeUnsafe(options.at ?? now)) - now)
629
+ )
630
+ : Effect.succeed(
631
+ options?.delay !== undefined
632
+ ? Duration.toMillis(options.delay)
633
+ : this.defaults.delayMs
634
+ )
635
+ // The enqueue span's context rides along on the record, so the
636
+ // handler's span joins the producing trace across processes.
637
+ const spanContext = Effect.currentSpan.pipe(
638
+ Effect.map((span) => ({
639
+ traceId: span.traceId,
640
+ spanId: span.spanId,
641
+ sampled: span.sampled
642
+ })),
643
+ Effect.catchTag("NoSuchElementError", () => Effect.succeed(undefined))
644
+ )
645
+ return Effect.all([Schema.encodeEffect(this.payloadJsonSchema)(payload), delayMs, spanContext]).pipe(
494
646
  Effect.orDie,
495
- Effect.flatMap((encoded) =>
647
+ Effect.flatMap(([encoded, resolvedDelayMs, capturedSpan]) =>
496
648
  Effect.flatMap(this.store, (store) =>
497
649
  store.enqueue({
498
650
  id,
@@ -512,9 +664,12 @@ const Proto = {
512
664
  ? Duration.toMillis(options.timeout)
513
665
  : this.defaults.timeoutMs,
514
666
  dedupe,
515
- delayMs: options?.delay !== undefined
516
- ? Duration.toMillis(options.delay)
517
- : this.defaults.delayMs
667
+ // `delayed` records scheduling INTENT (not queue backlog), so
668
+ // the worker's auto trace-linking stays deterministic.
669
+ trace: capturedSpan === undefined
670
+ ? undefined
671
+ : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
672
+ delayMs: resolvedDelayMs
518
673
  }))
519
674
  ),
520
675
  Effect.orDie,
@@ -537,6 +692,91 @@ const Proto = {
537
692
  )
538
693
  },
539
694
 
695
+ enqueueMany(this: AnyWithProps, payloads: ReadonlyArray<any>, options?: EnqueueManyOptions) {
696
+ return Effect.suspend(() => {
697
+ const queue = options?.queue !== undefined ? QueueName(options.queue) : this.queue
698
+ if (options?.at !== undefined && options.delay !== undefined) {
699
+ // Unrepresentable in TypeScript; guard untyped callers anyway.
700
+ throw new Error("effect-mq: set either `delay` or `at`, not both")
701
+ }
702
+ const delayMs = options?.at !== undefined
703
+ ? Effect.map(
704
+ Clock.currentTimeMillis,
705
+ (now) => Math.max(0, DateTime.toEpochMillis(DateTime.makeUnsafe(options.at ?? now)) - now)
706
+ )
707
+ : Effect.succeed(
708
+ options?.delay !== undefined
709
+ ? Duration.toMillis(options.delay)
710
+ : this.defaults.delayMs
711
+ )
712
+ const spanContext = Effect.currentSpan.pipe(
713
+ Effect.map((span) => ({
714
+ traceId: span.traceId,
715
+ spanId: span.spanId,
716
+ sampled: span.sampled
717
+ })),
718
+ Effect.catchTag("NoSuchElementError", () => Effect.succeed(undefined))
719
+ )
720
+ const encodedAll = Effect.forEach(payloads, (fields) => {
721
+ const payload = this.payloadSchema.make(fields)
722
+ return Effect.map(
723
+ Schema.encodeEffect(this.payloadJsonSchema)(payload),
724
+ (encoded) => ({ payload, encoded })
725
+ )
726
+ })
727
+ return Effect.all([encodedAll, delayMs, spanContext]).pipe(
728
+ Effect.orDie,
729
+ Effect.flatMap(([items, resolvedDelayMs, capturedSpan]) =>
730
+ Effect.flatMap(this.store, (store) =>
731
+ store.enqueueMany(items.map(({ encoded, payload }) => ({
732
+ id: this.idempotencyKey !== undefined
733
+ ? JobId(`${this._tag}/${this.idempotencyKey(payload)}`)
734
+ : undefined,
735
+ name: this._tag,
736
+ queue,
737
+ payload: encoded,
738
+ metadata: { ...this.metadata?.(payload), ...options?.metadata },
739
+ priority: options?.priority ?? this.defaults.priority,
740
+ attemptsMax: Math.max(1, options?.attempts ?? this.defaults.attempts),
741
+ backoff: options?.backoff !== undefined
742
+ ? normalizeBackoff(options.backoff)
743
+ : this.defaults.backoff,
744
+ keep: options?.keep !== undefined
745
+ ? normalizeKeep(options.keep)
746
+ : this.defaults.keep,
747
+ timeoutMs: options?.timeout !== undefined
748
+ ? Duration.toMillis(options.timeout)
749
+ : this.defaults.timeoutMs,
750
+ dedupe: normalizeDedupe(this.dedupe?.(payload)),
751
+ trace: capturedSpan === undefined
752
+ ? undefined
753
+ : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
754
+ delayMs: resolvedDelayMs
755
+ })))
756
+ )
757
+ ),
758
+ Effect.orDie,
759
+ Effect.tap((results) => {
760
+ const duplicates = results.filter((result) => result.duplicate).length
761
+ const update = (duplicate: "true" | "false", count: number) =>
762
+ count === 0 ? Effect.void : Metric.update(
763
+ Metrics.jobsEnqueued.pipe(
764
+ Metric.withAttributes({ name: this._tag, queue, duplicate })
765
+ ),
766
+ count
767
+ )
768
+ return Effect.andThen(
769
+ update("false", results.length - duplicates),
770
+ update("true", duplicates)
771
+ )
772
+ }),
773
+ Effect.map((results) => results.map((result) => result.id))
774
+ )
775
+ }).pipe(
776
+ Effect.withSpan(`${this._tag}.enqueueMany`, {}, { captureStackTrace: false })
777
+ )
778
+ },
779
+
540
780
  poll(this: AnyWithProps, jobId: JobId) {
541
781
  const self = this
542
782
  return Effect.flatMap(this.store, (store) =>
@@ -661,6 +901,15 @@ const Proto = {
661
901
  )
662
902
  },
663
903
 
904
+ cancelByKey(this: AnyWithProps, key: string) {
905
+ return Effect.flatMap(this.store, (store) =>
906
+ store.cancelByDedupe(this._tag, key).pipe(
907
+ Effect.catchTag("JobStoreError", (error) => Effect.die(error))
908
+ )).pipe(
909
+ Effect.withSpan(`${this._tag}.cancelByKey`, { attributes: { key } }, { captureStackTrace: false })
910
+ )
911
+ },
912
+
664
913
  promote(this: AnyWithProps, jobId: JobId) {
665
914
  return Effect.flatMap(this.store, (store) =>
666
915
  store.promote(jobId).pipe(
@@ -743,12 +992,14 @@ const Proto = {
743
992
 
744
993
  const boundMethods = [
745
994
  "enqueue",
995
+ "enqueueMany",
746
996
  "poll",
747
997
  "attempts",
748
998
  "awaitResult",
749
999
  "execute",
750
1000
  "retry",
751
1001
  "cancel",
1002
+ "cancelByKey",
752
1003
  "promote",
753
1004
  "schedule",
754
1005
  "unschedule",
@@ -805,8 +1056,11 @@ export const make = <
805
1056
  >(
806
1057
  name: Name,
807
1058
  options: {
1059
+ /** The payload schema: a `Schema.Struct` or its bare fields object. */
808
1060
  readonly payload: Payload
1061
+ /** Schema for the handler's success value (decodable via `awaitResult`/`attempts`). Default `Schema.Void`. */
809
1062
  readonly success?: Success | undefined
1063
+ /** Schema for the handler's typed failure (round-trips through storage). Default `Schema.Never`. */
810
1064
  readonly error?: Error | undefined
811
1065
  /**
812
1066
  * Derive a stable job id from the payload. Enqueueing the same key twice
@@ -855,6 +1109,7 @@ export const make = <
855
1109
  * Default: the default `JobStore`.
856
1110
  */
857
1111
  readonly store?: Context.Key<StoreId, StoreService> | undefined
1112
+ /** Default enqueue options (`delay`, `priority`, `attempts`, `backoff`, `keep`, `timeout`); per-enqueue options override. */
858
1113
  readonly defaults?: JobOptions | undefined
859
1114
  }
860
1115
  ): Job<
package/src/JobStore.ts CHANGED
@@ -242,6 +242,8 @@ export interface JobRecord {
242
242
  readonly cancelRequested: boolean
243
243
  /** The dedup key this job was enqueued under, if any (see `DedupePolicy`). */
244
244
  readonly dedupeKey: string | undefined
245
+ /** The producer's span context, restored as the handler span's parent. */
246
+ readonly trace: TraceContext | undefined
245
247
  /** Epoch millis before which the job must not be claimed. */
246
248
  readonly runAt: number
247
249
  readonly enqueuedAt: number
@@ -283,6 +285,25 @@ export interface DedupePolicy {
283
285
  readonly replace: boolean
284
286
  }
285
287
 
288
+ /**
289
+ * The producer's span context, persisted at enqueue so the handler's span
290
+ * can join the producing trace across processes (via `Tracer.externalSpan`).
291
+ *
292
+ * @since 0.4.0
293
+ */
294
+ export interface TraceContext {
295
+ readonly traceId: string
296
+ readonly spanId: string
297
+ readonly sampled: boolean
298
+ /**
299
+ * Whether the enqueue explicitly scheduled the job for the future
300
+ * (`delay`/`at`). Drives the worker's `traceLinking: "auto"` policy:
301
+ * immediate work continues the producer trace, future work starts its own
302
+ * trace with a causal link.
303
+ */
304
+ readonly delayed: boolean
305
+ }
306
+
286
307
  /**
287
308
  * @since 0.1.0
288
309
  */
@@ -304,6 +325,8 @@ export interface EnqueueRequest {
304
325
  readonly timeoutMs: number | undefined
305
326
  /** Deduplicate against other enqueues sharing `dedupe.key` (same name). */
306
327
  readonly dedupe: DedupePolicy | undefined
328
+ /** The producer's span context, for cross-process trace propagation. */
329
+ readonly trace: TraceContext | undefined
307
330
  readonly delayMs: number
308
331
  }
309
332
 
@@ -407,8 +430,9 @@ export interface ListResult {
407
430
  * A repeatable-job schedule as persisted by the store. Exactly one of `cron`
408
431
  * (with optional IANA `tz`) or `everyMs` is set. The payload is stored
409
432
  * schema-encoded, like job payloads. `nextRunAt` is maintained by the worker
410
- * sweep via `advanceSchedule`; ticks enqueue with the deterministic id
411
- * `sched/<key>/<slot>`, so concurrent sweepers dedup naturally.
433
+ * sweep via the atomic `tickSchedule` (CAS + insert + advance in one op),
434
+ * with tick jobs using the deterministic id `sched/<key>/<slot>`; concurrent
435
+ * sweepers lose the CAS, so each occurrence fires exactly once.
412
436
  *
413
437
  * @since 0.2.0
414
438
  */
@@ -599,6 +623,27 @@ export interface Service {
599
623
  request: EnqueueRequest
600
624
  ) => Effect.Effect<EnqueueResult, JobStoreError>
601
625
 
626
+ /**
627
+ * Insert many jobs in bulk: one store round trip per chunk of plain items
628
+ * (drivers chunk large batches); items carrying a dedup key run through
629
+ * the single-enqueue decision tree individually, in order. Results align
630
+ * positionally with the requests; each item carries full single-enqueue
631
+ * semantics (id dedup, dedup keys, delayed routing).
632
+ *
633
+ * The batch is NOT one transaction — items are independent, and a failure
634
+ * may leave a *subset* (not necessarily a prefix) applied. Safe under
635
+ * at-least-once: re-running a batch whose ids are deterministic skips
636
+ * what already landed, but items with store-assigned ids may re-insert.
637
+ * FIFO order within a priority holds except for an item whose
638
+ * auto/generated id collides with an existing row — it re-draws and lands
639
+ * after its batch-mates.
640
+ *
641
+ * @since 0.4.0
642
+ */
643
+ readonly enqueueMany: (
644
+ requests: ReadonlyArray<EnqueueRequest>
645
+ ) => Effect.Effect<ReadonlyArray<EnqueueResult>, JobStoreError>
646
+
602
647
  /**
603
648
  * Atomically: promote due delayed jobs, then claim the best runnable job
604
649
  * matching `queue` + `names` (highest priority first, FIFO within a
@@ -701,6 +746,20 @@ export interface Service {
701
746
  JobStoreError | JobNotFoundError | JobNotCancellableError
702
747
  >
703
748
 
749
+ /**
750
+ * Cancel whatever pending job is registered under a dedup key
751
+ * (name-scoped): pending states are cancelled exactly like `cancel`
752
+ * (waiting/delayed become terminal, active gets the heartbeat flag).
753
+ * Returns false when no pending job holds the key — idempotent by design,
754
+ * so "cancel it if anything is scheduled" needs no existence check.
755
+ *
756
+ * @since 0.4.0
757
+ */
758
+ readonly cancelByDedupe: (
759
+ name: string,
760
+ key: string
761
+ ) => Effect.Effect<boolean, JobStoreError>
762
+
704
763
  /** Move a delayed job to `waiting` now. */
705
764
  readonly promote: (
706
765
  id: JobId
@@ -745,6 +804,22 @@ export interface Service {
745
804
  JobStoreError
746
805
  >
747
806
 
807
+ /**
808
+ * Atomically claim one schedule occurrence: iff the schedule's `nextRunAt`
809
+ * still equals `expectedRunAt`, insert the tick job AND advance to
810
+ * `nextRunAt` in the same transaction, returning true. A stale sweeper's
811
+ * tick returns false without inserting — exactly-once per occurrence even
812
+ * when the previous slot's job row has been pruned by retention.
813
+ *
814
+ * @since 0.4.0
815
+ */
816
+ readonly tickSchedule: (
817
+ key: ScheduleKey,
818
+ expectedRunAt: number,
819
+ nextRunAt: number,
820
+ request: EnqueueRequest
821
+ ) => Effect.Effect<boolean, JobStoreError>
822
+
748
823
  /**
749
824
  * Advance a schedule's `nextRunAt` from `expectedRunAt` to `nextRunAt`
750
825
  * (conditional, so concurrent sweepers cannot regress it).