effect-mq 0.4.2 → 0.6.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 (64) hide show
  1. package/README.md +85 -17
  2. package/dist/Flow.d.ts +381 -0
  3. package/dist/Flow.d.ts.map +1 -0
  4. package/dist/Flow.js +340 -0
  5. package/dist/Flow.js.map +1 -0
  6. package/dist/Job.d.ts +37 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +17 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobSchedules.d.ts +112 -0
  11. package/dist/JobSchedules.d.ts.map +1 -0
  12. package/dist/JobSchedules.js +106 -0
  13. package/dist/JobSchedules.js.map +1 -0
  14. package/dist/JobStore.d.ts +320 -10
  15. package/dist/JobStore.d.ts.map +1 -1
  16. package/dist/JobStore.js.map +1 -1
  17. package/dist/MemoryJobStore.d.ts.map +1 -1
  18. package/dist/MemoryJobStore.js +336 -8
  19. package/dist/MemoryJobStore.js.map +1 -1
  20. package/dist/Metrics.d.ts +31 -0
  21. package/dist/Metrics.d.ts.map +1 -1
  22. package/dist/Metrics.js +39 -0
  23. package/dist/Metrics.js.map +1 -1
  24. package/dist/Worker.d.ts +120 -11
  25. package/dist/Worker.d.ts.map +1 -1
  26. package/dist/Worker.js +452 -26
  27. package/dist/Worker.js.map +1 -1
  28. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  29. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/DrizzleJobStore.js +662 -81
  31. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  32. package/dist/drizzle-postgres/schema.d.ts +310 -3
  33. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  34. package/dist/drizzle-postgres/schema.js +68 -1
  35. package/dist/drizzle-postgres/schema.js.map +1 -1
  36. package/dist/index.d.ts +14 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +14 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  41. package/dist/redis/RedisJobStore.js +221 -19
  42. package/dist/redis/RedisJobStore.js.map +1 -1
  43. package/dist/redis/scripts.d.ts +118 -11
  44. package/dist/redis/scripts.d.ts.map +1 -1
  45. package/dist/redis/scripts.js +497 -28
  46. package/dist/redis/scripts.js.map +1 -1
  47. package/dist/testing/conformance.d.ts +6 -0
  48. package/dist/testing/conformance.d.ts.map +1 -1
  49. package/dist/testing/conformance.js +765 -1
  50. package/dist/testing/conformance.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/Flow.ts +778 -0
  53. package/src/Job.ts +42 -11
  54. package/src/JobSchedules.ts +223 -0
  55. package/src/JobStore.ts +347 -9
  56. package/src/MemoryJobStore.ts +372 -8
  57. package/src/Metrics.ts +43 -0
  58. package/src/Worker.ts +726 -37
  59. package/src/drizzle-postgres/DrizzleJobStore.ts +827 -82
  60. package/src/drizzle-postgres/schema.ts +94 -0
  61. package/src/index.ts +16 -0
  62. package/src/redis/RedisJobStore.ts +291 -8
  63. package/src/redis/scripts.ts +529 -26
  64. package/src/testing/conformance.ts +989 -1
package/src/Job.ts CHANGED
@@ -63,7 +63,7 @@ export {
63
63
  */
64
64
  unrecoverable
65
65
  }
66
- import { type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
66
+ import { type CurrentJob, type RegisterOptions, Worker } from "./Worker.ts"
67
67
 
68
68
  const TypeId = "~effect-mq/Job" as const
69
69
 
@@ -308,7 +308,12 @@ export type EnqueueManyOptions = Omit<JobOptions, "delay"> & RunTimeInput & {
308
308
  readonly metadata?: Readonly<Record<string, string>> | undefined
309
309
  }
310
310
 
311
- interface ResolvedDefaults {
311
+ /**
312
+ * A definition's `defaults`, normalized to store units.
313
+ *
314
+ * @since 0.6.0
315
+ */
316
+ export interface ResolvedDefaults {
312
317
  readonly delayMs: number
313
318
  readonly priority: number
314
319
  readonly attempts: number
@@ -352,6 +357,12 @@ export interface ScheduleOptions<PayloadInput> {
352
357
  readonly keep?: KeepInput | undefined
353
358
  /** Per-run execution time limit for each occurrence. */
354
359
  readonly timeout?: Duration.Input | undefined
360
+ /**
361
+ * Ownership label for declarative reconciliation: `JobSchedules.layer`
362
+ * prunes only schedules carrying its own group. Unlabeled schedules are
363
+ * never pruned. Usually set by `JobSchedules`, not by hand.
364
+ */
365
+ readonly group?: string | undefined
355
366
  }
356
367
 
357
368
  /**
@@ -378,8 +389,12 @@ export interface JobAttempt<A, E> {
378
389
  readonly attempt: number
379
390
  readonly startedAt: number | undefined
380
391
  readonly finishedAt: number
381
- readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
382
- /** Absent for `stalled` and `cancelled` entries. */
392
+ readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled" | "fanned-out"
393
+ /**
394
+ * Absent for `stalled`, `cancelled`, and `fanned-out` entries — and for
395
+ * `failed` ones settled store-side without a handler exit (e.g. a
396
+ * fail-fast flow settle).
397
+ */
383
398
  readonly exit: Option.Option<Exit.Exit<A, E>>
384
399
  }
385
400
 
@@ -548,19 +563,20 @@ export interface Job<
548
563
 
549
564
  /**
550
565
  * Attach the handler that processes this job, as a layer to provide on top
551
- * of `Worker.layer` (bound to the same store).
566
+ * of `Worker.layer` (bound to the same store). The handler reads the
567
+ * running attempt from the `Worker.CurrentJob` service — the worker
568
+ * provides it per run, so it never appears in the layer's requirements.
552
569
  */
553
570
  readonly toLayer: <R>(
554
571
  handler: (
555
- payload: Payload["Type"],
556
- context: JobContext
572
+ payload: Payload["Type"]
557
573
  ) => Effect.Effect<Success["Type"], Error["Type"], R>,
558
574
  options?: RegisterOptions | undefined
559
575
  ) => Layer.Layer<
560
576
  never,
561
577
  never,
562
578
  | Worker
563
- | R
579
+ | Exclude<R, CurrentJob>
564
580
  | Payload["DecodingServices"]
565
581
  | Success["EncodingServices"]
566
582
  | Error["EncodingServices"]
@@ -583,7 +599,13 @@ const defaultPollSchedule = Schedule.min([
583
599
  Schedule.spaced("1 second")
584
600
  ])
585
601
 
586
- const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | undefined =>
602
+ /**
603
+ * Normalize a user-facing `BackoffInput` to the persisted policy. Shared
604
+ * with the flow runtime's child-spec builder.
605
+ *
606
+ * @internal
607
+ */
608
+ export const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | undefined =>
587
609
  input === undefined ? undefined : {
588
610
  _tag: input.type,
589
611
  delayMs: Duration.toMillis(input.delay),
@@ -595,7 +617,13 @@ const normalizeKeepState = (input: KeepStateInput): KeepStatePolicy => ({
595
617
  ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
596
618
  })
597
619
 
598
- const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined => {
620
+ /**
621
+ * Normalize a user-facing `KeepInput` to the persisted policy. Shared with
622
+ * the flow runtime's child-spec builder.
623
+ *
624
+ * @internal
625
+ */
626
+ export const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined => {
599
627
  if (input === undefined) return undefined
600
628
  const split = "completed" in input || "failed" in input || "cancelled" in input
601
629
  const flat = "count" in input || "age" in input
@@ -691,6 +719,7 @@ const Proto = {
691
719
  trace: capturedSpan === undefined
692
720
  ? undefined
693
721
  : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
722
+ parent: undefined,
694
723
  delayMs: resolvedDelayMs
695
724
  }))
696
725
  ),
@@ -773,6 +802,7 @@ const Proto = {
773
802
  trace: capturedSpan === undefined
774
803
  ? undefined
775
804
  : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
805
+ parent: undefined,
776
806
  delayMs: resolvedDelayMs
777
807
  })))
778
808
  )
@@ -984,6 +1014,7 @@ const Proto = {
984
1014
  timeoutMs: options.timeout !== undefined
985
1015
  ? Duration.toMillis(options.timeout)
986
1016
  : self.defaults.timeoutMs,
1017
+ group: options.group,
987
1018
  nextRunAt
988
1019
  }
989
1020
  const store = yield* self.store
@@ -1003,7 +1034,7 @@ const Proto = {
1003
1034
 
1004
1035
  toLayer(
1005
1036
  this: AnyWithProps,
1006
- handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
1037
+ handler: (payload: any) => Effect.Effect<any, any, any>,
1007
1038
  options?: RegisterOptions
1008
1039
  ) {
1009
1040
  return Layer.effectDiscard(
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Declarative schedule reconciliation.
3
+ *
4
+ * `.schedule()` is imperative: it creates and updates, and a schedule whose
5
+ * call was deleted from code keeps firing forever (deletion drift). This
6
+ * module declares the FULL schedule set for a service as a layer; on
7
+ * startup it upserts everything declared (idempotent, cadence-preserving)
8
+ * and detects group members that are no longer declared:
9
+ *
10
+ * ```ts
11
+ * const SchedulesLive = JobSchedules.layer({
12
+ * group: "billing-service",
13
+ * schedules: [
14
+ * JobSchedules.schedule(SendDigest, "daily", { cron: "0 9 * * *", payload: {} }),
15
+ * JobSchedules.schedule(GenerateInvoice, "monthly", { cron: "0 0 1 * *", payload: {} })
16
+ * ],
17
+ * removal: "group", // default "warn": log drift, prune nothing
18
+ * removeAfter: "10 minutes" // optional grace window for rolling deploys
19
+ * })
20
+ * ```
21
+ *
22
+ * Safety model: pruning is scoped to the ownership `group`. Schedules
23
+ * created by plain `.schedule()` calls carry no group and are NEVER pruned;
24
+ * other groups' schedules are never touched. The default `removal: "warn"`
25
+ * only logs — destructive pruning is an explicit opt-in.
26
+ *
27
+ * @since 0.5.0
28
+ */
29
+ import { type Context, Duration, Effect, Layer } from "effect"
30
+ import type { ScheduleOptions } from "./Job.ts"
31
+ import type { ScheduleKey, Service as StoreService } from "./JobStore.ts"
32
+
33
+ /**
34
+ * The structural view of a `Job.make` class that `schedule` needs: its tag,
35
+ * its store key, and its bound `schedule` verb.
36
+ *
37
+ * @since 0.5.0
38
+ */
39
+ export interface SchedulableJob<PayloadInput, R> {
40
+ readonly _tag: string
41
+ readonly store: Context.Key<any, StoreService>
42
+ readonly schedule: (
43
+ key: string,
44
+ options: ScheduleOptions<PayloadInput>
45
+ ) => Effect.Effect<ScheduleKey, never, R>
46
+ }
47
+
48
+ /**
49
+ * One declared schedule: a job, its key, and its cadence/options. Built
50
+ * with `JobSchedules.schedule`; consumed by `JobSchedules.layer`.
51
+ *
52
+ * @since 0.5.0
53
+ */
54
+ export interface ScheduleEntry<R> {
55
+ readonly jobName: string
56
+ readonly key: string
57
+ readonly store: Context.Key<any, StoreService>
58
+ readonly register: (group: string) => Effect.Effect<ScheduleKey, never, R>
59
+ }
60
+
61
+ /**
62
+ * Declare one schedule for the reconciled set. Identical semantics to
63
+ * `MyJob.schedule(key, options)` — including cadence preservation on
64
+ * unchanged `cron`/`tz`/`every` — plus the layer's ownership `group`.
65
+ *
66
+ * @since 0.5.0
67
+ */
68
+ export const schedule = <PayloadInput, R>(
69
+ job: SchedulableJob<PayloadInput, R>,
70
+ key: string,
71
+ options: Omit<ScheduleOptions<PayloadInput>, "group">
72
+ ): ScheduleEntry<R> => ({
73
+ jobName: job._tag,
74
+ key,
75
+ store: job.store,
76
+ register: (group) => job.schedule(key, { ...options, group })
77
+ })
78
+
79
+ /**
80
+ * Options for `JobSchedules.layer`.
81
+ *
82
+ * @since 0.5.0
83
+ */
84
+ export interface ReconcileOptions<
85
+ Entries extends ReadonlyArray<ScheduleEntry<any>>,
86
+ Stores extends ReadonlyArray<Context.Key<any, StoreService>>
87
+ > {
88
+ /**
89
+ * Ownership label. Everything this layer declares is upserted with this
90
+ * group, and only schedules carrying this group are candidates for drift
91
+ * detection and pruning. Use one group per service/deployable.
92
+ */
93
+ readonly group: string
94
+ /** The full declared schedule set for this group. */
95
+ readonly schedules: Entries
96
+ /**
97
+ * What to do with group members that are no longer declared:
98
+ * - `"warn"` (default): log them at warning level, prune nothing.
99
+ * - `"group"`: remove them (never touches unlabeled or other-group rows).
100
+ */
101
+ readonly removal?: "warn" | "group" | undefined
102
+ /**
103
+ * Grace window before pruning (requires `removal: "group"`). During a
104
+ * rolling deploy, replicas on the previous release re-declare schedules
105
+ * the new release dropped; pruning immediately and re-adding would
106
+ * re-anchor `every` grids and double-fire crons. With a window, the prune
107
+ * runs this long after startup, re-checks the store, and removes only
108
+ * members still undeclared. Shutting down before the window fires skips
109
+ * the prune (the next startup re-evaluates).
110
+ */
111
+ readonly removeAfter?: Duration.Input | undefined
112
+ /**
113
+ * Extra store keys to reconcile even when no declared entry references
114
+ * them — needed when a release drops the LAST schedule a store had, since
115
+ * drift detection only reaches stores it can see.
116
+ */
117
+ readonly stores?: Stores | undefined
118
+ }
119
+
120
+ const reconcile = (options: {
121
+ readonly group: string
122
+ readonly schedules: ReadonlyArray<ScheduleEntry<any>>
123
+ readonly removal?: "warn" | "group" | undefined
124
+ readonly removeAfter?: Duration.Input | undefined
125
+ readonly stores?: ReadonlyArray<Context.Key<any, StoreService>> | undefined
126
+ }) =>
127
+ Effect.gen(function*() {
128
+ const removal = options.removal ?? "warn"
129
+ if (options.removeAfter !== undefined && removal !== "group") {
130
+ return yield* Effect.die(
131
+ new Error(`effect-mq: \`removeAfter\` requires \`removal: "group"\``)
132
+ )
133
+ }
134
+ // Duplicate declarations are a config bug: the second would silently
135
+ // overwrite the first's cadence/payload on every startup.
136
+ const seen = new Set<string>()
137
+ for (const entry of options.schedules) {
138
+ const id = `${entry.jobName}/${entry.key}`
139
+ if (seen.has(id)) {
140
+ return yield* Effect.die(
141
+ new Error(`effect-mq: schedule "${id}" is declared twice in group "${options.group}"`)
142
+ )
143
+ }
144
+ seen.add(id)
145
+ }
146
+
147
+ // Upsert every declared schedule, collecting the declared key set per
148
+ // store (Context.Key identity groups entries onto their stores).
149
+ const byStore = new Map<Context.Key<any, StoreService>, Set<string>>()
150
+ for (const storeKey of options.stores ?? []) {
151
+ byStore.set(storeKey, new Set())
152
+ }
153
+ for (const entry of options.schedules) {
154
+ const registered = yield* entry.register(options.group)
155
+ const declared = byStore.get(entry.store) ?? new Set<string>()
156
+ declared.add(registered)
157
+ byStore.set(entry.store, declared)
158
+ }
159
+
160
+ // Drift detection per store: group members not in the declared set.
161
+ for (const [storeKey, declared] of byStore) {
162
+ const store = yield* storeKey
163
+ const prune = Effect.gen(function*() {
164
+ const members = yield* store.listSchedules({ group: options.group }).pipe(
165
+ Effect.retry({ times: 5 }),
166
+ Effect.orDie
167
+ )
168
+ const undeclared = members.filter((member) => !declared.has(member.key))
169
+ if (undeclared.length === 0) return
170
+ if (removal === "warn") {
171
+ return yield* Effect.logWarning(
172
+ `effect-mq: ${undeclared.length} schedule(s) in group "${options.group}" ` +
173
+ `are no longer declared and keep firing; \`removal: "group"\` would prune them`,
174
+ undeclared.map((member) => member.key)
175
+ )
176
+ }
177
+ for (const member of undeclared) {
178
+ yield* store.removeSchedule(member.key).pipe(Effect.retry({ times: 5 }), Effect.orDie)
179
+ }
180
+ yield* Effect.logInfo(
181
+ `effect-mq: pruned ${undeclared.length} undeclared schedule(s) from group "${options.group}"`,
182
+ undeclared.map((member) => member.key)
183
+ )
184
+ })
185
+ if (removal === "group" && options.removeAfter !== undefined) {
186
+ // Deferred prune, tied to the layer scope: it re-lists at fire time,
187
+ // so anything re-declared during the window survives.
188
+ yield* prune.pipe(
189
+ Effect.delay(Duration.toMillis(options.removeAfter)),
190
+ Effect.catchCause((cause) =>
191
+ Effect.logError(
192
+ `effect-mq: deferred schedule prune failed (group "${options.group}")`,
193
+ cause
194
+ )
195
+ ),
196
+ Effect.forkScoped
197
+ )
198
+ } else {
199
+ yield* prune
200
+ }
201
+ }
202
+ })
203
+
204
+ /**
205
+ * Reconcile the declared schedule set on startup: upsert everything
206
+ * declared, then warn about (or, with `removal: "group"`, prune) group
207
+ * members that are no longer declared. See the module docs for the safety
208
+ * model.
209
+ *
210
+ * @since 0.5.0
211
+ */
212
+ export const layer = <
213
+ const Entries extends ReadonlyArray<ScheduleEntry<any>>,
214
+ const Stores extends ReadonlyArray<Context.Key<any, StoreService>> = readonly []
215
+ >(
216
+ options: ReconcileOptions<Entries, Stores>
217
+ ): Layer.Layer<never, never, EntryServices<Entries[number]> | StoreId<Stores[number]>> =>
218
+ Layer.effectDiscard(reconcile(options))
219
+
220
+ // Naked-parameter conditionals so empty tuples distribute to `never` instead
221
+ // of inferring `unknown` from nothing.
222
+ type EntryServices<E> = E extends ScheduleEntry<infer R> ? R : never
223
+ type StoreId<K> = K extends Context.Key<infer Id, StoreService> ? Id : never