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/Flow.ts ADDED
@@ -0,0 +1,778 @@
1
+ /**
2
+ * Cross-store parent-child flows.
3
+ *
4
+ * A flow's PARENT job fans out N child jobs, parks in `waiting-children`
5
+ * until every child settles, then resumes with their typed results. The
6
+ * children may live on a **different store** than the parent — a
7
+ * cron-scheduled parent in Postgres can fan out thousands of idempotent
8
+ * sends into Redis and collect the outcomes back in Postgres.
9
+ *
10
+ * ```ts
11
+ * const DigestFlow = Flow.make("daily-digest", {
12
+ * parent: SendDigest, // a Job bound to the Postgres store
13
+ * children: [SendEmail], // Jobs, each bound to their own store
14
+ * onChildFailure: "continue" // default; "fail" settles on first failure
15
+ * })
16
+ *
17
+ * // the parent worker runs both phases (requires the parent AND child stores)
18
+ * const DigestWorker = DigestFlow.toLayer({
19
+ * fanOut: (payload) =>
20
+ * Effect.gen(function*() {
21
+ * const users = yield* Users.active
22
+ * return Flow.children(SendEmail, users.map((user) => ({
23
+ * key: user.id,
24
+ * payload: { userId: user.id }
25
+ * })))
26
+ * }),
27
+ * collect: (payload, results) =>
28
+ * Effect.succeed({ sent: results.counts.completed, failed: results.counts.failed })
29
+ * // or: yield* results.all (materialized buckets), results.stream (paged)
30
+ * })
31
+ *
32
+ * // workers that run the CHILDREN list the flow so results push instantly:
33
+ * // Worker.layer({ store: EmailStore, flows: [DigestFlow] })
34
+ * ```
35
+ *
36
+ * Architecture (see `designs/parent-child-flows.md`): the parent's store
37
+ * owns the flow — child manifest, per-child results, outcome counters — so
38
+ * "the flow settles exactly once" is single-store atomic. Cross-store needs
39
+ * only two at-least-once idempotent mechanisms: every terminal child
40
+ * transition atomically appends its report to the CHILD store's outbox,
41
+ * which worker relays *push* into the parent store in batches (children
42
+ * keep completing even while the parent store is down); and the parent
43
+ * worker's flow sweeper *reconciles* from child-store state (repairs
44
+ * everything the push path can miss: crashes mid-enqueue, dropped outbox
45
+ * entries, children terminal on stores no relay reaches).
46
+ *
47
+ * Flows nest: a child may itself be another flow's parent, reporting upward
48
+ * through the same machinery when it settles. Depth is capped (8) so a
49
+ * cyclic definition surfaces as an unrecoverable failure instead of an
50
+ * unbounded chain.
51
+ *
52
+ * Flow children bypass the child definition's `idempotencyKey`/`dedupe` —
53
+ * the child `key` (unique within the flow) IS the idempotency mechanism,
54
+ * carried in the deterministic job id. Handlers should be idempotent, as
55
+ * everywhere under at-least-once.
56
+ *
57
+ * @since 0.6.0
58
+ */
59
+ import { Cause, Context, Duration, Effect, Exit, Layer, Option, Schema, Scope as Scope_, Stream, Tracer } from "effect"
60
+ import {
61
+ type AnyStructSchema,
62
+ type JobOptions,
63
+ normalizeBackoff,
64
+ normalizeKeep,
65
+ type ResolvedDefaults
66
+ } from "./Job.ts"
67
+ import {
68
+ type EnqueueRequest,
69
+ type FlowChildRecord,
70
+ type FlowState,
71
+ JobId,
72
+ type QueueName,
73
+ type Service as StoreService,
74
+ unrecoverable
75
+ } from "./JobStore.ts"
76
+ import { type CurrentJob, type FlowDescriptor, type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
77
+
78
+ /**
79
+ * The structural view of a `Job.make` class a flow needs from its members.
80
+ * Contravariant callback members are typed with `never` parameters so every
81
+ * concrete job satisfies the constraint; the runtime only ever passes a
82
+ * job's own payload back into them.
83
+ *
84
+ * @since 0.6.0
85
+ */
86
+ export interface MemberJob {
87
+ readonly _tag: string
88
+ readonly queue: QueueName
89
+ readonly store: Context.Key<any, StoreService>
90
+ readonly payloadSchema: AnyStructSchema
91
+ readonly payloadJsonSchema: Schema.Top
92
+ readonly successSchema: Schema.Top
93
+ readonly errorSchema: Schema.Top
94
+ readonly exitSchema: Schema.Top
95
+ readonly defaults: ResolvedDefaults
96
+ readonly metadata: ((payload: never) => Readonly<Record<string, string>>) | undefined
97
+ readonly retryable: ((error: never) => boolean) | undefined
98
+ }
99
+
100
+ /**
101
+ * The structural view of the parent job: a `MemberJob` plus the producer
102
+ * surface the flow delegates (`Flow.enqueue` IS the parent's `enqueue`).
103
+ *
104
+ * @since 0.6.0
105
+ */
106
+ export interface ParentJob extends MemberJob {
107
+ readonly enqueue: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
108
+ readonly enqueueMany: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
109
+ readonly execute: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
110
+ readonly poll: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
111
+ readonly attempts: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
112
+ readonly awaitResult: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
113
+ readonly retry: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
114
+ readonly cancel: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
115
+ readonly promote: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
116
+ readonly schedule: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
117
+ readonly unschedule: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
118
+ }
119
+
120
+ // Naked-parameter conditionals so unions of member jobs distribute (and
121
+ // empty tuples land on `never`, not `unknown`).
122
+ type NameOf<J> = J extends { readonly _tag: infer N extends string } ? N : never
123
+ type PayloadMakeIn<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema } ? P["~type.make.in"]
124
+ : never
125
+ type PayloadType<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema } ? P["Type"] : never
126
+ type PayloadEncodingServices<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema }
127
+ ? P["EncodingServices"]
128
+ : never
129
+ type SuccessValue<J> = J extends { readonly successSchema: infer S extends Schema.Top } ? S["Type"] : never
130
+ type SuccessDecodingServices<J> = J extends { readonly successSchema: infer S extends Schema.Top }
131
+ ? S["DecodingServices"]
132
+ : never
133
+ type SuccessEncodingServices<J> = J extends { readonly successSchema: infer S extends Schema.Top }
134
+ ? S["EncodingServices"]
135
+ : never
136
+ type ErrorValue<J> = J extends { readonly errorSchema: infer E extends Schema.Top } ? E["Type"] : never
137
+ type ErrorDecodingServices<J> = J extends { readonly errorSchema: infer E extends Schema.Top }
138
+ ? E["DecodingServices"]
139
+ : never
140
+ type ErrorEncodingServices<J> = J extends { readonly errorSchema: infer E extends Schema.Top }
141
+ ? E["EncodingServices"]
142
+ : never
143
+ type PayloadDecodingServices<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema }
144
+ ? P["DecodingServices"]
145
+ : never
146
+
147
+ /**
148
+ * The StoreIds of a flow's members — what `toLayer` requires so the parent
149
+ * worker is guaranteed able to reconcile and cascade into every child store.
150
+ *
151
+ * @since 0.6.0
152
+ */
153
+ export type MemberStores<J> = J extends { readonly store: Context.Key<infer Id, StoreService> } ? Id : never
154
+
155
+ /**
156
+ * Per-child options accepted by `Flow.children` items — the shared
157
+ * `JobOptions` minus `delay` (flow children run immediately), plus
158
+ * `metadata` merged over the child definition's callback.
159
+ *
160
+ * @since 0.6.0
161
+ */
162
+ export type ChildOptions = Omit<JobOptions, "delay"> & {
163
+ readonly metadata?: Readonly<Record<string, string>> | undefined
164
+ }
165
+
166
+ /**
167
+ * One child to fan out: its flow-unique `key` (the idempotency mechanism —
168
+ * re-runs of `fanOut` re-produce the same child, never a second one), the
169
+ * payload, and optional per-child options.
170
+ *
171
+ * @since 0.6.0
172
+ */
173
+ export interface ChildItem<PayloadInput> {
174
+ readonly key: string
175
+ readonly payload: PayloadInput
176
+ readonly options?: ChildOptions | undefined
177
+ }
178
+
179
+ /**
180
+ * A group of children of one member type, built by `Flow.children`. A
181
+ * `fanOut` handler returns one group or an array of them.
182
+ *
183
+ * @since 0.6.0
184
+ */
185
+ export interface ChildGroup<out J extends MemberJob = MemberJob> {
186
+ readonly job: J
187
+ readonly items: ReadonlyArray<ChildItem<PayloadMakeIn<J>>>
188
+ }
189
+
190
+ /**
191
+ * A naked-parameter distribution of `ChildGroup` over a union of members,
192
+ * so a group literal must pair a member's `job` with THAT member's payloads
193
+ * (the undistributed `ChildGroup<A | B>` would accept `A`'s job with `B`'s
194
+ * items).
195
+ *
196
+ * @since 0.6.0
197
+ */
198
+ export type GroupsOf<J> = J extends MemberJob ? ChildGroup<J> : never
199
+
200
+ /**
201
+ * What `fanOut` returns: children of any of the flow's member types.
202
+ *
203
+ * @since 0.6.0
204
+ */
205
+ export type ChildrenInput<J extends MemberJob> = GroupsOf<J> | ReadonlyArray<GroupsOf<J>>
206
+
207
+ /**
208
+ * The deterministic id of a flow child. Every component is an arbitrary
209
+ * string (store keys, ids, and child keys may all contain "/"), so the two
210
+ * variable-length boundaries are pinned by a length prefix — distinct
211
+ * (storeKey, flowId, childKey) triples can never alias.
212
+ *
213
+ * @internal
214
+ */
215
+ export const childJobId = (parentStoreKey: string, flowId: string, childKey: string): string =>
216
+ `flow/${parentStoreKey.length}.${flowId.length}/${parentStoreKey}/${flowId}/${childKey}`
217
+
218
+ /**
219
+ * Declare children of one member type. Payloads are validated through the
220
+ * child's schema when the specs are built; duplicate keys (across ALL
221
+ * groups) fail the fan-out unrecoverably.
222
+ *
223
+ * @since 0.6.0
224
+ */
225
+ export const children = <J extends MemberJob>(
226
+ job: J,
227
+ items: ReadonlyArray<ChildItem<PayloadMakeIn<J>>>
228
+ ): ChildGroup<J> => ({ job, items })
229
+
230
+ /**
231
+ * A child that completed, with its decoded success value.
232
+ *
233
+ * @since 0.6.0
234
+ */
235
+ export interface CompletedChild<Name extends string, A> {
236
+ readonly key: string
237
+ readonly name: Name
238
+ readonly value: A
239
+ }
240
+
241
+ /**
242
+ * A child that failed terminally. `cause` carries the decoded typed failure
243
+ * — or a die for store-side failures that never produced an exit (stall
244
+ * exhaustion, a nested parent's fail-fast settle).
245
+ *
246
+ * @since 0.6.0
247
+ */
248
+ export interface FailedChild<Name extends string, E> {
249
+ readonly key: string
250
+ readonly name: Name
251
+ readonly cause: Cause.Cause<E>
252
+ }
253
+
254
+ /**
255
+ * A child that was cancelled — directly in its store, or by a flow settle
256
+ * (fail-fast, or a cancel of the waiting parent).
257
+ *
258
+ * @since 0.6.0
259
+ */
260
+ export interface CancelledChild<Name extends string> {
261
+ readonly key: string
262
+ readonly name: Name
263
+ }
264
+
265
+ type CompletedOf<J> = J extends MemberJob ? CompletedChild<NameOf<J>, SuccessValue<J>> : never
266
+ type FailedOf<J> = J extends MemberJob ? FailedChild<NameOf<J>, ErrorValue<J>> : never
267
+ type CancelledOf<J> = J extends MemberJob ? CancelledChild<NameOf<J>> : never
268
+
269
+ /**
270
+ * Per-outcome tallies, read straight off the parent's persisted `FlowState`
271
+ * — no dependency-row reads. When `collect` runs, `pending` is 0.
272
+ *
273
+ * @since 0.6.0
274
+ */
275
+ export interface ChildCounts {
276
+ readonly pending: number
277
+ readonly completed: number
278
+ readonly failed: number
279
+ readonly cancelled: number
280
+ }
281
+
282
+ /**
283
+ * One settled child, discriminated by `outcome` (and by `name` across
284
+ * member types), decoded through its member's schemas.
285
+ *
286
+ * @since 0.6.0
287
+ */
288
+ export type SettledChild<J extends MemberJob> =
289
+ | ({ readonly outcome: "completed" } & CompletedOf<J>)
290
+ | ({ readonly outcome: "failed" } & FailedOf<J>)
291
+ | ({ readonly outcome: "cancelled" } & CancelledOf<J>)
292
+
293
+ /**
294
+ * Every settled child, materialized into outcome buckets.
295
+ *
296
+ * @since 0.6.0
297
+ */
298
+ export interface SettledChildren<J extends MemberJob> {
299
+ readonly completed: ReadonlyArray<Extract<SettledChild<J>, { readonly outcome: "completed" }>>
300
+ readonly failed: ReadonlyArray<Extract<SettledChild<J>, { readonly outcome: "failed" }>>
301
+ readonly cancelled: ReadonlyArray<Extract<SettledChild<J>, { readonly outcome: "cancelled" }>>
302
+ }
303
+
304
+ /**
305
+ * What `collect` (and `Flow.childResults`) receives: `counts` for free,
306
+ * `all` to materialize the outcome buckets (one array — fine into the
307
+ * tens of thousands), and `stream` to fold huge flows one page at a time.
308
+ *
309
+ * @since 0.6.0
310
+ */
311
+ export interface ChildResults<J extends MemberJob> {
312
+ readonly counts: ChildCounts
313
+ readonly all: Effect.Effect<SettledChildren<J>>
314
+ readonly stream: Stream.Stream<SettledChild<J>>
315
+ }
316
+
317
+ /**
318
+ * The two-phase flow handler. `fanOut` runs once per flow (persisted phase
319
+ * dispatch — a resumed parent can never fan out twice) and returns the
320
+ * children; `collect` runs after every child settled, with their results.
321
+ * Both draw on the PARENT's attempt budget: a failing `fanOut` retries
322
+ * against it until the manifest lands, a failing `collect` retries with
323
+ * what remains.
324
+ *
325
+ * @since 0.6.0
326
+ */
327
+ export interface FlowHandlers<
328
+ Parent extends ParentJob,
329
+ Children extends ReadonlyArray<MemberJob>,
330
+ R1,
331
+ R2
332
+ > {
333
+ readonly fanOut: (
334
+ payload: PayloadType<Parent>
335
+ ) => Effect.Effect<ChildrenInput<Children[number]>, ErrorValue<Parent>, R1>
336
+ readonly collect: (
337
+ payload: PayloadType<Parent>,
338
+ results: ChildResults<Children[number]>
339
+ ) => Effect.Effect<SuccessValue<Parent>, ErrorValue<Parent>, R2>
340
+ }
341
+
342
+ /**
343
+ * A flow definition. The producer surface (`enqueue`, `execute`, `poll`,
344
+ * `awaitResult`, `schedule`, ...) IS the parent job's — a flow parent is a
345
+ * real job row in the parent store, and a scheduled parent needs no flow
346
+ * awareness in its schedule row.
347
+ *
348
+ * Note on fail-fast (`onChildFailure: "fail"`): the first failed child
349
+ * settles the parent terminally `failed` store-side (`failedReason` names
350
+ * the child; there is no parent exit, so `awaitResult` dies — like stall
351
+ * exhaustion) and the remaining children are cancelled. The failed child's
352
+ * own exit stays inspectable via `childResults`. An admin `retry` of the
353
+ * parent re-enters `collect` with the mixed results.
354
+ *
355
+ * @since 0.6.0
356
+ */
357
+ export interface Flow<
358
+ Name extends string,
359
+ Parent extends ParentJob,
360
+ Children extends ReadonlyArray<MemberJob>
361
+ > {
362
+ readonly name: Name
363
+ readonly parent: Parent
364
+ readonly children: Children
365
+ /** True when `onChildFailure` is `"fail"`. */
366
+ readonly failFast: boolean
367
+
368
+ readonly enqueue: Parent["enqueue"]
369
+ readonly enqueueMany: Parent["enqueueMany"]
370
+ readonly execute: Parent["execute"]
371
+ readonly poll: Parent["poll"]
372
+ readonly attempts: Parent["attempts"]
373
+ readonly awaitResult: Parent["awaitResult"]
374
+ readonly retry: Parent["retry"]
375
+ readonly cancel: Parent["cancel"]
376
+ readonly promote: Parent["promote"]
377
+ readonly schedule: Parent["schedule"]
378
+ readonly unschedule: Parent["unschedule"]
379
+
380
+ /**
381
+ * The flow's recorded child results, in any parent state: live `counts`
382
+ * plus the `all`/`stream` accessors — children still pending are absent
383
+ * from both.
384
+ */
385
+ readonly childResults: (
386
+ flowId: JobId
387
+ ) => Effect.Effect<
388
+ ChildResults<Children[number]>,
389
+ never,
390
+ | MemberStores<Parent>
391
+ | SuccessDecodingServices<Children[number]>
392
+ | ErrorDecodingServices<Children[number]>
393
+ >
394
+
395
+ /**
396
+ * Register the parent's two phases on a worker (bound to the parent's
397
+ * store). Requires every member store — this is what makes the parent
398
+ * worker the one process guaranteed capable of reconciling and cascading
399
+ * across the whole flow.
400
+ */
401
+ readonly toLayer: <R1, R2>(
402
+ handlers: FlowHandlers<Parent, Children, R1, R2>,
403
+ options?: RegisterOptions | undefined
404
+ ) => Layer.Layer<
405
+ never,
406
+ never,
407
+ | Worker
408
+ // The worker provides CurrentJob around both phases.
409
+ | Exclude<R1, CurrentJob>
410
+ | Exclude<R2, CurrentJob>
411
+ | MemberStores<Parent>
412
+ | MemberStores<Children[number]>
413
+ | PayloadDecodingServices<Parent>
414
+ | SuccessEncodingServices<Parent>
415
+ | ErrorEncodingServices<Parent>
416
+ | PayloadEncodingServices<Children[number]>
417
+ | SuccessDecodingServices<Children[number]>
418
+ | ErrorDecodingServices<Children[number]>
419
+ >
420
+ }
421
+
422
+ /**
423
+ * Define a flow over existing job definitions.
424
+ *
425
+ * Throws (synchronously, at definition time) on duplicate child names or a
426
+ * parent listed among its own children (direct self-recursion). Nesting
427
+ * across DIFFERENT flows is supported — see the module docs.
428
+ *
429
+ * @since 0.6.0
430
+ */
431
+ export const make = <
432
+ const Name extends string,
433
+ Parent extends ParentJob,
434
+ const Children extends ReadonlyArray<MemberJob>
435
+ >(
436
+ name: Name,
437
+ options: {
438
+ /** The parent job: its store owns the flow. */
439
+ readonly parent: Parent
440
+ /** The closed set of member definitions `fanOut` may produce. */
441
+ readonly children: Children
442
+ /**
443
+ * What a failed child does to the flow:
444
+ * - `"continue"` (default): every child settles; `collect` sees the
445
+ * failures in `results.failed`.
446
+ * - `"fail"`: the first failed child settles the parent as `failed`
447
+ * and cancels the remaining children (see the `Flow` docs).
448
+ */
449
+ readonly onChildFailure?: "continue" | "fail" | undefined
450
+ }
451
+ ): Flow<Name, Parent, Children> => {
452
+ const parent = options.parent
453
+ const byName = new Map<string, MemberJob>()
454
+ for (const child of options.children) {
455
+ if (byName.has(child._tag)) {
456
+ throw new Error(`effect-mq: flow "${name}" declares child "${child._tag}" twice`)
457
+ }
458
+ byName.set(child._tag, child)
459
+ }
460
+ if (byName.has(parent._tag)) {
461
+ throw new Error(
462
+ `effect-mq: flow "${name}" uses "${parent._tag}" as both its parent and one of its own children`
463
+ )
464
+ }
465
+ const failFast = options.onChildFailure === "fail"
466
+
467
+ // A dependency row decoded into a settled, tagged entry. The outcome
468
+ // follows the DECODED exit where one exists (a defensive net for
469
+ // outcome/exit drift); rows without an exit follow their recorded status.
470
+ // Pending rows decode to undefined (only reachable via `childResults` on
471
+ // an unsettled flow).
472
+ type SettledEntry =
473
+ | { readonly outcome: "completed"; readonly key: string; readonly name: string; readonly value: unknown }
474
+ | { readonly outcome: "failed"; readonly key: string; readonly name: string; readonly cause: Cause.Cause<unknown> }
475
+ | { readonly outcome: "cancelled"; readonly key: string; readonly name: string }
476
+ const decodeRow = (row: FlowChildRecord) =>
477
+ Effect.gen(function*() {
478
+ if (row.status === "pending") return undefined
479
+ if (row.status === "cancelled") {
480
+ const entry: SettledEntry = { outcome: "cancelled", key: row.childKey, name: row.name }
481
+ return entry
482
+ }
483
+ if (row.exit === undefined) {
484
+ // Store-side failure: no exit was ever produced.
485
+ const entry: SettledEntry = {
486
+ outcome: "failed",
487
+ key: row.childKey,
488
+ name: row.name,
489
+ cause: Cause.die(
490
+ new Error(row.failedReason ?? `effect-mq: flow child "${row.childKey}" failed without a result`)
491
+ )
492
+ }
493
+ return entry
494
+ }
495
+ const member = byName.get(row.name)
496
+ if (member === undefined) {
497
+ const entry: SettledEntry = {
498
+ outcome: "failed",
499
+ key: row.childKey,
500
+ name: row.name,
501
+ cause: Cause.die(
502
+ new Error(`effect-mq: flow "${name}" has no member definition for child "${row.name}"`)
503
+ )
504
+ }
505
+ return entry
506
+ }
507
+ const decoded = yield* Schema.decodeUnknownEffect(member.exitSchema)(row.exit).pipe(Effect.orDie)
508
+ // SAFETY: a member's exitSchema Type is Exit<Success, Error> by
509
+ // construction in Job.make.
510
+ const exit = decoded as Exit.Exit<unknown, unknown>
511
+ const entry: SettledEntry = Exit.isSuccess(exit)
512
+ ? { outcome: "completed", key: row.childKey, name: row.name, value: exit.value }
513
+ : { outcome: "failed", key: row.childKey, name: row.name, cause: exit.cause }
514
+ return entry
515
+ })
516
+
517
+ // Build the collect-facing accessors over a flow's dependency rows.
518
+ // Accessors are lazy and may run in the caller's fiber later, so they
519
+ // carry the context captured at construction (the registration context
520
+ // under a worker; the caller's own context via `childResults`) — that is
521
+ // what lets their public types declare no requirements.
522
+ const makeResults = (
523
+ store: StoreService,
524
+ flow: FlowState,
525
+ flowId: JobId,
526
+ services: Context.Context<never>
527
+ ) => {
528
+ // SAFETY: the captured context carries the members' decoding services,
529
+ // declared on toLayer's / childResults' public signatures.
530
+ const withCaptured = <A, E>(effect: Effect.Effect<A, E, unknown>): Effect.Effect<A, E> =>
531
+ effect.pipe(
532
+ Effect.updateContext((input) => Context.merge(input, services) as Context.Context<unknown>)
533
+ ) as Effect.Effect<A, E>
534
+ const decodePage = (cursor: string | undefined) =>
535
+ store.listChildResults(flowId, { cursor }).pipe(
536
+ Effect.orDie,
537
+ Effect.flatMap((page) =>
538
+ Effect.forEach(page.items, decodeRow).pipe(
539
+ Effect.map((entries) =>
540
+ [
541
+ entries.filter((entry) => entry !== undefined),
542
+ Option.fromNullishOr(page.cursor)
543
+ ] as const
544
+ )
545
+ )
546
+ )
547
+ )
548
+ const stream = Stream.paginate<string | undefined, SettledEntry>(
549
+ undefined,
550
+ (cursor) => withCaptured(decodePage(cursor))
551
+ )
552
+ const all = withCaptured(Effect.gen(function*() {
553
+ const completed: Array<SettledEntry> = []
554
+ const failed: Array<SettledEntry> = []
555
+ const cancelled: Array<SettledEntry> = []
556
+ let cursor: string | undefined
557
+ do {
558
+ const [entries, next] = yield* decodePage(cursor)
559
+ for (const entry of entries) {
560
+ if (entry.outcome === "completed") completed.push(entry)
561
+ else if (entry.outcome === "failed") failed.push(entry)
562
+ else cancelled.push(entry)
563
+ }
564
+ cursor = Option.getOrUndefined(next)
565
+ } while (cursor !== undefined)
566
+ return { completed, failed, cancelled }
567
+ }))
568
+ const counts = {
569
+ pending: flow.pending,
570
+ completed: flow.completed,
571
+ failed: flow.failed,
572
+ cancelled: flow.cancelled
573
+ }
574
+ return { counts, all, stream }
575
+ }
576
+
577
+ // Turn the fanOut handler's groups into complete FlowChildSpecs:
578
+ // deterministic ids, `parent` envelopes, encoded payloads, and the
579
+ // fan-out run's span context for cross-store trace linking. Definition
580
+ // bugs (unknown members, duplicate keys) die UNRECOVERABLY — retrying a
581
+ // deterministic bug burns the budget for nothing.
582
+ const buildSpecs = (
583
+ input: ChildrenInput<MemberJob>,
584
+ context: JobContext,
585
+ parentDepth: number
586
+ ) =>
587
+ Effect.gen(function*() {
588
+ // Depth rides the parent envelope explicitly (never parsed out of ids
589
+ // — keys and ids are arbitrary user strings). Definitions cannot be
590
+ // cycle-checked statically (a job does not know which flows parent
591
+ // it), so a runaway A→B→A recursion surfaces here instead of growing
592
+ // forever.
593
+ const depth = parentDepth + 1
594
+ if (depth > 8) {
595
+ return yield* Effect.die(unrecoverable(
596
+ new Error(
597
+ `effect-mq: flow "${name}" exceeds the nesting depth limit (8) — flow definitions must not form cycles`
598
+ )
599
+ ))
600
+ }
601
+ const groups = Array.isArray(input) ? input : [input]
602
+ const span = yield* Effect.currentSpan.pipe(
603
+ Effect.map((current) => ({
604
+ traceId: current.traceId,
605
+ spanId: current.spanId,
606
+ sampled: current.sampled,
607
+ delayed: false
608
+ })),
609
+ Effect.catchTag("NoSuchElementError", () => Effect.succeed(undefined))
610
+ )
611
+ const seen = new Set<string>()
612
+ const specs: Array<{ childKey: string; storeKey: string; request: EnqueueRequest }> = []
613
+ for (const group of groups) {
614
+ const member = byName.get(group.job._tag)
615
+ if (member === undefined) {
616
+ return yield* Effect.die(unrecoverable(
617
+ new Error(`effect-mq: flow "${name}" fanned out to "${group.job._tag}", which is not a declared child`)
618
+ ))
619
+ }
620
+ for (const item of group.items) {
621
+ if (item.key === "") {
622
+ return yield* Effect.die(unrecoverable(
623
+ new Error(`effect-mq: flow "${name}" produced a child with an empty key`)
624
+ ))
625
+ }
626
+ if (seen.has(item.key)) {
627
+ return yield* Effect.die(unrecoverable(
628
+ new Error(`effect-mq: flow "${name}" produced duplicate child key "${item.key}"`)
629
+ ))
630
+ }
631
+ seen.add(item.key)
632
+ // Payload validation/encode failures are deterministic definition
633
+ // bugs like duplicate keys: retrying the fan-out would burn the
634
+ // whole attempt budget on the same throw, so die unrecoverably.
635
+ let payload: unknown
636
+ try {
637
+ payload = member.payloadSchema.make(item.payload)
638
+ } catch (error) {
639
+ return yield* Effect.die(unrecoverable(new Error(
640
+ `effect-mq: flow "${name}" child "${item.key}" payload failed validation: ${String(error)}`
641
+ )))
642
+ }
643
+ const encoded = yield* Schema.encodeEffect(member.payloadJsonSchema)(payload).pipe(
644
+ Effect.catch((error) =>
645
+ Effect.die(unrecoverable(new Error(
646
+ `effect-mq: flow "${name}" child "${item.key}" payload failed to encode: ${String(error)}`
647
+ )))
648
+ )
649
+ )
650
+ // SAFETY: `metadata` is declared with a `never` parameter only to
651
+ // make the structural constraint universal; at runtime it accepts
652
+ // its own definition's payload, which is what `payload` is.
653
+ const memberMetadata = member.metadata?.(payload as never)
654
+ const itemOptions = item.options
655
+ specs.push({
656
+ childKey: item.key,
657
+ storeKey: member.store.key,
658
+ request: {
659
+ // Namespaced by the parent store key: two parent stores
660
+ // sharing one child store can never collide on parent ids.
661
+ id: JobId(childJobId(parent.store.key, context.jobId, item.key)),
662
+ name: member._tag,
663
+ queue: member.queue,
664
+ payload: encoded,
665
+ metadata: { ...memberMetadata, ...itemOptions?.metadata },
666
+ priority: itemOptions?.priority ?? member.defaults.priority,
667
+ attemptsMax: Math.max(1, itemOptions?.attempts ?? member.defaults.attempts),
668
+ backoff: itemOptions?.backoff !== undefined
669
+ ? normalizeBackoff(itemOptions.backoff)
670
+ : member.defaults.backoff,
671
+ keep: itemOptions?.keep !== undefined
672
+ ? normalizeKeep(itemOptions.keep)
673
+ : member.defaults.keep,
674
+ timeoutMs: itemOptions?.timeout !== undefined
675
+ ? Duration.toMillis(itemOptions.timeout)
676
+ : member.defaults.timeoutMs,
677
+ // The child key is the idempotency mechanism; the member's
678
+ // idempotencyKey/dedupe callbacks do NOT apply to flow
679
+ // children (see the module docs).
680
+ dedupe: undefined,
681
+ trace: span,
682
+ parent: {
683
+ flowName: name,
684
+ flowId: context.jobId,
685
+ childKey: item.key,
686
+ parentStoreKey: parent.store.key,
687
+ depth
688
+ },
689
+ delayMs: 0
690
+ }
691
+ })
692
+ }
693
+ }
694
+ return specs
695
+ })
696
+
697
+ // The lazy accessors and phase handlers run under a captured context;
698
+ // never capture the ambient Scope or span (they must come from the
699
+ // executing fiber), mirroring the worker's registration capture.
700
+ const captureServices = Effect.map(
701
+ Effect.context<never>(),
702
+ (context) => context.pipe(Context.omit(Scope_.Scope, Tracer.ParentSpan))
703
+ )
704
+
705
+ const toLayer = (
706
+ handlers: FlowHandlers<ParentJob, ReadonlyArray<MemberJob>, unknown, unknown>,
707
+ registerOptions?: RegisterOptions | undefined
708
+ ) => {
709
+ const descriptor: FlowDescriptor = {
710
+ name,
711
+ parent: {
712
+ _tag: parent._tag,
713
+ queue: parent.queue,
714
+ store: parent.store,
715
+ payloadJsonSchema: parent.payloadJsonSchema,
716
+ exitSchema: parent.exitSchema,
717
+ retryable: parent.retryable
718
+ },
719
+ failFast,
720
+ childStores: options.children.map((child) => child.store),
721
+ fanOut: (payload, context, parentDepth) =>
722
+ // SAFETY: the worker decodes through the parent's payload schema
723
+ // before calling this, so `payload` is the parent's payload type.
724
+ handlers.fanOut(payload as never).pipe(
725
+ Effect.flatMap((produced) => buildSpecs(produced, context, parentDepth))
726
+ ),
727
+ collect: (payload, flowState, context) =>
728
+ Effect.gen(function*() {
729
+ const store = yield* parent.store
730
+ const services = yield* captureServices
731
+ const results = makeResults(store, flowState, context.jobId, services)
732
+ // SAFETY: same as fanOut for `payload`; the accessors decode
733
+ // through each member's schemas, matching ChildResults.
734
+ return yield* handlers.collect(payload as never, results as never)
735
+ })
736
+ }
737
+ return Layer.effectDiscard(
738
+ Effect.flatMap(Worker, (worker) => worker.registerFlow(descriptor, registerOptions))
739
+ )
740
+ }
741
+
742
+ const emptyFlow: FlowState = { failFast, pending: 0, completed: 0, failed: 0, cancelled: 0 }
743
+ const childResults = (flowId: JobId) =>
744
+ Effect.gen(function*() {
745
+ const store = yield* parent.store
746
+ const services = yield* captureServices
747
+ const record = yield* store.getJob(flowId).pipe(Effect.orDie)
748
+ const flowState = Option.isSome(record) && record.value.flow !== undefined
749
+ ? record.value.flow
750
+ : emptyFlow
751
+ return makeResults(store, flowState, flowId, services)
752
+ })
753
+
754
+ // SAFETY: the `Flow` interface re-declares the precise member signatures
755
+ // (delegated producer methods carry the parent's own types; toLayer's
756
+ // requirements are declared there); the implementation is assembled
757
+ // dynamically, mirroring Job.make's prototype pattern.
758
+ const flow: any = {
759
+ name,
760
+ parent,
761
+ children: options.children,
762
+ failFast,
763
+ enqueue: parent.enqueue,
764
+ enqueueMany: parent.enqueueMany,
765
+ execute: parent.execute,
766
+ poll: parent.poll,
767
+ attempts: parent.attempts,
768
+ awaitResult: parent.awaitResult,
769
+ retry: parent.retry,
770
+ cancel: parent.cancel,
771
+ promote: parent.promote,
772
+ schedule: parent.schedule,
773
+ unschedule: parent.unschedule,
774
+ childResults,
775
+ toLayer
776
+ }
777
+ return flow
778
+ }