effect-mq 0.5.0 → 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 (59) 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 +31 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +16 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobStore.d.ts +312 -10
  11. package/dist/JobStore.d.ts.map +1 -1
  12. package/dist/JobStore.js.map +1 -1
  13. package/dist/MemoryJobStore.d.ts.map +1 -1
  14. package/dist/MemoryJobStore.js +334 -7
  15. package/dist/MemoryJobStore.js.map +1 -1
  16. package/dist/Metrics.d.ts +31 -0
  17. package/dist/Metrics.d.ts.map +1 -1
  18. package/dist/Metrics.js +39 -0
  19. package/dist/Metrics.js.map +1 -1
  20. package/dist/Worker.d.ts +120 -11
  21. package/dist/Worker.d.ts.map +1 -1
  22. package/dist/Worker.js +452 -26
  23. package/dist/Worker.js.map +1 -1
  24. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  25. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  26. package/dist/drizzle-postgres/DrizzleJobStore.js +653 -77
  27. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  28. package/dist/drizzle-postgres/schema.d.ts +293 -3
  29. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/schema.js +66 -1
  31. package/dist/drizzle-postgres/schema.js.map +1 -1
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  37. package/dist/redis/RedisJobStore.js +219 -18
  38. package/dist/redis/RedisJobStore.js.map +1 -1
  39. package/dist/redis/scripts.d.ts +117 -10
  40. package/dist/redis/scripts.d.ts.map +1 -1
  41. package/dist/redis/scripts.js +492 -25
  42. package/dist/redis/scripts.js.map +1 -1
  43. package/dist/testing/conformance.d.ts +6 -0
  44. package/dist/testing/conformance.d.ts.map +1 -1
  45. package/dist/testing/conformance.js +728 -1
  46. package/dist/testing/conformance.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/Flow.ts +778 -0
  49. package/src/Job.ts +35 -11
  50. package/src/JobStore.ts +339 -9
  51. package/src/MemoryJobStore.ts +370 -7
  52. package/src/Metrics.ts +43 -0
  53. package/src/Worker.ts +726 -37
  54. package/src/drizzle-postgres/DrizzleJobStore.ts +817 -78
  55. package/src/drizzle-postgres/schema.ts +92 -0
  56. package/src/index.ts +8 -0
  57. package/src/redis/RedisJobStore.ts +289 -8
  58. package/src/redis/scripts.ts +524 -24
  59. package/src/testing/conformance.ts +945 -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
@@ -384,8 +389,12 @@ export interface JobAttempt<A, E> {
384
389
  readonly attempt: number
385
390
  readonly startedAt: number | undefined
386
391
  readonly finishedAt: number
387
- readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
388
- /** 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
+ */
389
398
  readonly exit: Option.Option<Exit.Exit<A, E>>
390
399
  }
391
400
 
@@ -554,19 +563,20 @@ export interface Job<
554
563
 
555
564
  /**
556
565
  * Attach the handler that processes this job, as a layer to provide on top
557
- * 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.
558
569
  */
559
570
  readonly toLayer: <R>(
560
571
  handler: (
561
- payload: Payload["Type"],
562
- context: JobContext
572
+ payload: Payload["Type"]
563
573
  ) => Effect.Effect<Success["Type"], Error["Type"], R>,
564
574
  options?: RegisterOptions | undefined
565
575
  ) => Layer.Layer<
566
576
  never,
567
577
  never,
568
578
  | Worker
569
- | R
579
+ | Exclude<R, CurrentJob>
570
580
  | Payload["DecodingServices"]
571
581
  | Success["EncodingServices"]
572
582
  | Error["EncodingServices"]
@@ -589,7 +599,13 @@ const defaultPollSchedule = Schedule.min([
589
599
  Schedule.spaced("1 second")
590
600
  ])
591
601
 
592
- 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 =>
593
609
  input === undefined ? undefined : {
594
610
  _tag: input.type,
595
611
  delayMs: Duration.toMillis(input.delay),
@@ -601,7 +617,13 @@ const normalizeKeepState = (input: KeepStateInput): KeepStatePolicy => ({
601
617
  ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
602
618
  })
603
619
 
604
- 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 => {
605
627
  if (input === undefined) return undefined
606
628
  const split = "completed" in input || "failed" in input || "cancelled" in input
607
629
  const flat = "count" in input || "age" in input
@@ -697,6 +719,7 @@ const Proto = {
697
719
  trace: capturedSpan === undefined
698
720
  ? undefined
699
721
  : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
722
+ parent: undefined,
700
723
  delayMs: resolvedDelayMs
701
724
  }))
702
725
  ),
@@ -779,6 +802,7 @@ const Proto = {
779
802
  trace: capturedSpan === undefined
780
803
  ? undefined
781
804
  : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
805
+ parent: undefined,
782
806
  delayMs: resolvedDelayMs
783
807
  })))
784
808
  )
@@ -1010,7 +1034,7 @@ const Proto = {
1010
1034
 
1011
1035
  toLayer(
1012
1036
  this: AnyWithProps,
1013
- handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
1037
+ handler: (payload: any) => Effect.Effect<any, any, any>,
1014
1038
  options?: RegisterOptions
1015
1039
  ) {
1016
1040
  return Layer.effectDiscard(
package/src/JobStore.ts CHANGED
@@ -67,6 +67,8 @@ export const ScheduleKey: Brand.Constructor<ScheduleKey> = Brand.nominal<Schedul
67
67
  * - `waiting`: runnable now, ordered by (priority desc, enqueue order asc)
68
68
  * - `delayed`: must not run before `runAt`
69
69
  * - `active`: claimed by a worker holding a lock token
70
+ * - `waiting-children`: a flow parent parked until its children settle (see
71
+ * `AckOutcome`'s `FanOut`); never claimable, not terminal
70
72
  * - `completed` / `failed`: terminal, with an encoded `Exit` stored
71
73
  *
72
74
  * @since 0.1.0
@@ -75,6 +77,7 @@ export type JobState =
75
77
  | "waiting"
76
78
  | "delayed"
77
79
  | "active"
80
+ | "waiting-children"
78
81
  | "completed"
79
82
  | "failed"
80
83
  | "cancelled"
@@ -205,8 +208,12 @@ export interface AttemptRecord {
205
208
  readonly startedAt: number | undefined
206
209
  /** Ack/recovery time of this run (epoch millis). */
207
210
  readonly finishedAt: number
208
- readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
209
- /** Schema-encoded `Exit`; undefined for `stalled`. */
211
+ readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled" | "fanned-out"
212
+ /**
213
+ * Schema-encoded `Exit`; undefined for `stalled`, `cancelled`, and
214
+ * `fanned-out` entries, and for `failed` entries written by a store-side
215
+ * settle (a fail-fast flow parent) rather than a handler run.
216
+ */
210
217
  readonly exit: unknown
211
218
  }
212
219
 
@@ -244,6 +251,10 @@ export interface JobRecord {
244
251
  readonly dedupeKey: string | undefined
245
252
  /** The producer's span context, restored as the handler span's parent. */
246
253
  readonly trace: TraceContext | undefined
254
+ /** Present on flow children: the link back to their parent flow. */
255
+ readonly parent: ParentEnvelope | undefined
256
+ /** Present on flow parents once their manifest landed (see `FlowState`). */
257
+ readonly flow: FlowState | undefined
247
258
  /** Epoch millis before which the job must not be claimed. */
248
259
  readonly runAt: number
249
260
  readonly enqueuedAt: number
@@ -304,6 +315,171 @@ export interface TraceContext {
304
315
  readonly delayed: boolean
305
316
  }
306
317
 
318
+ /**
319
+ * The persisted link from a flow child job to its parent flow. Attached by
320
+ * the flow runtime at fan-out (never by producers). Its presence puts the
321
+ * job under the outbox invariant: the child's store appends its report to
322
+ * the outbox with every terminal transition (see `OutboxEntry`), and a
323
+ * worker's relay delivers it into the parent's store (see
324
+ * `Worker.layer({ flows })`).
325
+ *
326
+ * @since 0.6.0
327
+ */
328
+ export interface ParentEnvelope {
329
+ /** The flow definition's name (`Flow.make(name, ...)`). */
330
+ readonly flowName: string
331
+ /** The parent job's id in the parent store. */
332
+ readonly flowId: JobId
333
+ /** This child's key, unique within the flow (the idempotency mechanism). */
334
+ readonly childKey: string
335
+ /** The parent store's context-key string, for cross-store report routing. */
336
+ readonly parentStoreKey: string
337
+ /**
338
+ * This child's nesting level: 1 for children of a top-level flow, one
339
+ * more per level of nesting. Carried explicitly (never parsed out of
340
+ * ids — user keys are arbitrary strings) so the fan-out depth cap can
341
+ * catch cyclic definitions.
342
+ */
343
+ readonly depth: number
344
+ }
345
+
346
+ /**
347
+ * Flow bookkeeping persisted on a parent job by the `FanOut` ack. Its
348
+ * presence IS the phase marker: absent means the parent has not fanned out
349
+ * yet (a claim dispatches `fanOut`); present means the manifest landed (a
350
+ * claim dispatches `collect`, and a re-run can never fan out twice).
351
+ *
352
+ * The four counters always sum to the manifest size: applied reports move
353
+ * one child from `pending` to its outcome bucket, and settle-time marking
354
+ * (fail-fast, parent cancel) moves every remaining `pending` child to
355
+ * `cancelled` in the same atomic op. `collect` reads its tallies from here
356
+ * without touching a single dependency row.
357
+ *
358
+ * @since 0.6.0
359
+ */
360
+ export interface FlowState {
361
+ /** When true, the first failed child report settles the parent as failed. */
362
+ readonly failFast: boolean
363
+ /** Children whose result has not been recorded yet. */
364
+ readonly pending: number
365
+ readonly completed: number
366
+ readonly failed: number
367
+ readonly cancelled: number
368
+ }
369
+
370
+ /**
371
+ * One child of a flow fan-out, persisted on its dependency row so the flow
372
+ * sweeper can (re-)enqueue the child from storage alone after any crash.
373
+ * `request.id` is the deterministic flow child id — derived from the parent
374
+ * store key, flow id, and child key, so re-enqueues are idempotent — and
375
+ * `request.parent` carries the envelope.
376
+ *
377
+ * @since 0.6.0
378
+ */
379
+ export interface FlowChildSpec {
380
+ readonly childKey: string
381
+ /** The CHILD store's context-key string (children may live elsewhere). */
382
+ readonly storeKey: string
383
+ readonly request: EnqueueRequest
384
+ }
385
+
386
+ /**
387
+ * A dependency row: one child's status and result as recorded in the PARENT
388
+ * store. `exit` is the child's schema-encoded exit; `failedReason` carries
389
+ * store-side child failures (stall exhaustion, a nested parent's fail-fast
390
+ * settle) that never produced an exit. `cascaded` marks that a cancel no
391
+ * longer needs to be delivered into the child's store (set by
392
+ * `markChildrenCascaded`, or immediately when the recorded outcome came
393
+ * FROM the child's store).
394
+ *
395
+ * @since 0.6.0
396
+ */
397
+ export interface FlowChildRecord {
398
+ readonly flowId: JobId
399
+ readonly childKey: string
400
+ readonly name: string
401
+ readonly storeKey: string
402
+ readonly childJobId: JobId
403
+ readonly status: "pending" | "completed" | "failed" | "cancelled"
404
+ readonly exit: unknown
405
+ readonly failedReason: string | undefined
406
+ readonly cascaded: boolean
407
+ }
408
+
409
+ /**
410
+ * An idempotent child-result report delivered into the parent store — by a
411
+ * worker's outbox relay (the push path) or synthesized by the flow sweeper
412
+ * from child-store state (the reconcile path). Both may deliver the same
413
+ * report; the dependency row's state dedups them.
414
+ *
415
+ * @since 0.6.0
416
+ */
417
+ export interface FlowChildReport {
418
+ readonly flowId: JobId
419
+ readonly childKey: string
420
+ readonly outcome: "completed" | "failed" | "cancelled"
421
+ /** The child's schema-encoded exit; undefined for store-side failures. */
422
+ readonly exit: unknown
423
+ /** Present when the child was failed store-side (no exit exists). */
424
+ readonly failedReason: string | undefined
425
+ }
426
+
427
+ /**
428
+ * One undelivered child-result report in a CHILD store's outbox.
429
+ *
430
+ * The outbox invariant every driver must uphold: whenever a store operation
431
+ * moves a job carrying a `parent` envelope INTO a terminal state — a
432
+ * `Complete`/`Fail`/`Cancelled` ack, stall exhaustion, a direct `cancel` of
433
+ * a waiting/delayed child, a cancel honoured during release or stall
434
+ * recovery, or a fail-fast settle of a NESTED flow parent (its terminal
435
+ * transition happens store-side, with no ack) — the same atomic operation
436
+ * appends the corresponding report here. The worker's relay then drains
437
+ * the outbox into the parent store in batches and deletes what it
438
+ * delivered. Because dependency rows dedup redelivery, the relay needs no
439
+ * leases: crash anywhere and the entries are simply delivered again.
440
+ *
441
+ * `remove` appends nothing (an operator override), and a `FanOut` ack
442
+ * appends nothing (`waiting-children` is not terminal).
443
+ *
444
+ * @since 0.6.0
445
+ */
446
+ export interface OutboxEntry {
447
+ /** Store-assigned, opaque; pass back to `deleteOutbox` verbatim. */
448
+ readonly id: string
449
+ readonly flowName: string
450
+ /** The parent store's context-key string, for relay routing. */
451
+ readonly parentStoreKey: string
452
+ readonly report: FlowChildReport
453
+ }
454
+
455
+ /**
456
+ * The flow sweeper's work list, scoped by parent state so settled flows
457
+ * never re-drive work:
458
+ *
459
+ * - `reconcile`: for parents still in `waiting-children`, dependency rows
460
+ * `pending` longer than the caller's threshold, with their stored specs —
461
+ * the sweeper checks the child's store and either enqueues the child
462
+ * (missing) or synthesizes its report (terminal).
463
+ * - `cascade`: rows marked `cancelled` by a settle but not yet delivered as
464
+ * real cancels into their child store (any parent state).
465
+ *
466
+ * @since 0.6.0
467
+ */
468
+ export interface FlowSweepWork {
469
+ readonly reconcile: ReadonlyArray<{
470
+ readonly flowId: JobId
471
+ readonly children: ReadonlyArray<FlowChildSpec>
472
+ }>
473
+ readonly cascade: ReadonlyArray<{
474
+ readonly flowId: JobId
475
+ readonly children: ReadonlyArray<{
476
+ readonly childKey: string
477
+ readonly storeKey: string
478
+ readonly childJobId: JobId
479
+ }>
480
+ }>
481
+ }
482
+
307
483
  /**
308
484
  * @since 0.1.0
309
485
  */
@@ -327,6 +503,8 @@ export interface EnqueueRequest {
327
503
  readonly dedupe: DedupePolicy | undefined
328
504
  /** The producer's span context, for cross-process trace propagation. */
329
505
  readonly trace: TraceContext | undefined
506
+ /** Flow-parent link, set only by the flow runtime (opaque to the store). */
507
+ readonly parent: ParentEnvelope | undefined
330
508
  readonly delayMs: number
331
509
  }
332
510
 
@@ -389,7 +567,18 @@ export type ClaimResult =
389
567
  * attempts accounting) is computed by the worker; the store only applies it.
390
568
  *
391
569
  * Every outcome appends an `AttemptRecord` to the job's ledger (`Complete` →
392
- * completed, `Retry` → retried, `Fail` → failed).
570
+ * completed, `Retry` → retried, `Fail` → failed, `FanOut` → fanned-out).
571
+ *
572
+ * `FanOut` (flow parents only) atomically: persists `FlowState` (phase
573
+ * marker + policy + `pending = children.length`), inserts one `pending`
574
+ * dependency row per child (spec stored, so crash recovery needs only the
575
+ * parent store), and parks the parent in `waiting-children` — or, for an
576
+ * empty spec, settles it straight to `waiting`. It does NOT consume an
577
+ * attempt (a phase transition is not a completed run), and it does NOT
578
+ * enqueue the children (the flow runtime and sweeper own that). A parent
579
+ * whose `flow` is already present keeps its persisted manifest untouched —
580
+ * the new children are ignored and the state transition follows the existing
581
+ * `pending` count, so a double fan-out cannot duplicate children.
393
582
  *
394
583
  * @since 0.1.0
395
584
  */
@@ -398,6 +587,11 @@ export type AckOutcome =
398
587
  | { readonly _tag: "Retry"; readonly delayMs: number; readonly exit: unknown }
399
588
  | { readonly _tag: "Fail"; readonly exit: unknown }
400
589
  | { readonly _tag: "Cancelled" }
590
+ | {
591
+ readonly _tag: "FanOut"
592
+ readonly failFast: boolean
593
+ readonly children: ReadonlyArray<FlowChildSpec>
594
+ }
401
595
 
402
596
  /**
403
597
  * Filters and pagination for `list`. Results are ordered newest-first
@@ -663,7 +857,8 @@ export interface Service {
663
857
  * Acknowledge a claimed job. Verifies the lock token, releases the lock,
664
858
  * increments `attemptsMade`, appends to the attempts ledger, then applies
665
859
  * the outcome (`Complete`/`Fail` are terminal and apply the record's `keep`
666
- * policy; `Retry` re-queues after `delayMs`).
860
+ * policy; `Retry` re-queues after `delayMs`). Terminal outcomes for jobs
861
+ * carrying a `parent` envelope also append an `OutboxEntry` atomically.
667
862
  */
668
863
  readonly ack: (
669
864
  id: JobId,
@@ -693,7 +888,8 @@ export interface Service {
693
888
  * Sweep active jobs whose lock has expired. Each recovered job gets
694
889
  * `stalledCount + 1` and a `stalled` ledger entry; jobs exceeding
695
890
  * `maxStalledCount` are failed (`failed: true` in the result), the rest
696
- * return to `waiting`.
891
+ * return to `waiting`. Stall-exhausting a job that carries a `parent`
892
+ * envelope appends its failed report to the outbox atomically.
697
893
  */
698
894
  readonly recoverStalled: (options: {
699
895
  readonly maxStalledCount: number
@@ -730,7 +926,10 @@ export interface Service {
730
926
  /**
731
927
  * Re-run a failed job: back to `waiting` with a fresh attempt budget
732
928
  * (`attemptsMade`/`stalledCount` reset, terminal fields cleared). The
733
- * attempts ledger is preserved and keeps numbering monotonically.
929
+ * attempts ledger is preserved and keeps numbering monotonically. A flow
930
+ * parent's `flow` field and dependency rows survive: a retried fail-fast
931
+ * flow re-enters the `collect` phase with its recorded (mixed) results —
932
+ * it can never fan out twice.
734
933
  */
735
934
  readonly retry: (
736
935
  id: JobId
@@ -742,8 +941,13 @@ export interface Service {
742
941
  /**
743
942
  * Cancel a job. Waiting/delayed jobs become terminal (`cancelled`)
744
943
  * immediately; active jobs get `cancelRequested` set, and the owning
745
- * worker interrupts the handler on its next heartbeat. Terminal jobs fail
746
- * with `JobNotCancellableError`.
944
+ * worker interrupts the handler on its next heartbeat. A `waiting-children`
945
+ * flow parent settles to `cancelled` AND flips its remaining `pending`
946
+ * dependency rows to `cancelled` (not `cascaded`) in the same atomic op —
947
+ * the flow sweeper then delivers real cancels into the child stores, and
948
+ * late child reports find their row terminal and drop. Cancelling a job
949
+ * that itself carries a `parent` envelope appends its cancelled report to
950
+ * the outbox atomically. Terminal jobs fail with `JobNotCancellableError`.
747
951
  */
748
952
  readonly cancel: (
749
953
  id: JobId
@@ -838,11 +1042,137 @@ export interface Service {
838
1042
  nextRunAt: number
839
1043
  ) => Effect.Effect<void, JobStoreError>
840
1044
 
1045
+ /**
1046
+ * Record a batch of child outcomes on their dependency rows — idempotent
1047
+ * and atomic, results positional. Each report applies only when its row
1048
+ * is still `pending` (`applied: false` for duplicate, late, or unknown
1049
+ * reports); an applied report moves the child from the parent's `pending`
1050
+ * counter to its outcome counter and marks the row `cascaded` (the
1051
+ * outcome came from the child's store — no cancel needs delivering). A
1052
+ * batch may span flows.
1053
+ *
1054
+ * Per flow, all row updates apply BEFORE the settle decision, and the
1055
+ * parent settles at most once per batch (`parentSettled: true` on the
1056
+ * report that decided it): when `pending` hits zero, `waiting-children` →
1057
+ * `waiting` (runnable now, phase `collect`) — or on the first applied
1058
+ * `failed` report in batch order under the fail-fast policy, which
1059
+ * instead settles the parent terminally `failed` (store-side,
1060
+ * `failedReason` set, no exit — like stall exhaustion) and flips every
1061
+ * remaining `pending` row to `cancelled`/not-`cascaded` in the same
1062
+ * atomic op. Fail-fast wins the tie when one report triggers both rules.
1063
+ *
1064
+ * Lock ordering (drivers MUST follow it): dependency rows first, the
1065
+ * parent row second — reports, fail-fast marking, and cancel marking all
1066
+ * take locks in this order, so report-vs-settle cannot deadlock.
1067
+ *
1068
+ * Drivers may process very large batches in atomic sub-batches (the
1069
+ * Redis driver chunks at 500); the apply-all-before-settle rule then
1070
+ * holds per sub-batch. Worker relays never exceed one page (500), so
1071
+ * this only shows on direct store calls with larger batches.
1072
+ *
1073
+ * @since 0.6.0
1074
+ */
1075
+ readonly recordChildResults: (
1076
+ reports: ReadonlyArray<FlowChildReport>
1077
+ ) => Effect.Effect<
1078
+ ReadonlyArray<{ readonly applied: boolean; readonly parentSettled: boolean }>,
1079
+ JobStoreError
1080
+ >
1081
+
1082
+ /**
1083
+ * The oldest undelivered outbox entries, up to `limit` (see
1084
+ * `OutboxEntry` for the append invariant). The relay peeks, delivers via
1085
+ * `recordChildResults` on the parent store, then deletes — redelivery
1086
+ * after a crash is safe because dependency rows dedup.
1087
+ *
1088
+ * `after` pages past a previously-returned entry id (exclusive), whether
1089
+ * or not that entry still exists — the relay walks the whole outbox this
1090
+ * way, so entries it cannot route (their parent store is not provided
1091
+ * here) never blockade the ones behind them. Anything other than an id
1092
+ * this store issued may be treated as unset.
1093
+ *
1094
+ * @since 0.6.0
1095
+ */
1096
+ readonly peekOutbox: (options: {
1097
+ readonly limit: number
1098
+ readonly after?: string | undefined
1099
+ }) => Effect.Effect<ReadonlyArray<OutboxEntry>, JobStoreError>
1100
+
1101
+ /**
1102
+ * Delete delivered outbox entries by id. Idempotent; unknown ids are
1103
+ * ignored.
1104
+ *
1105
+ * @since 0.6.0
1106
+ */
1107
+ readonly deleteOutbox: (
1108
+ ids: ReadonlyArray<string>
1109
+ ) => Effect.Effect<void, JobStoreError>
1110
+
1111
+ /**
1112
+ * A flow's dependency rows, ordered by child key; feeds `collect` and
1113
+ * dashboards. Pass the returned `cursor` back for the next page.
1114
+ *
1115
+ * @since 0.6.0
1116
+ */
1117
+ readonly listChildResults: (
1118
+ flowId: JobId,
1119
+ options?: {
1120
+ readonly cursor?: string | undefined
1121
+ /** Page size; default 1000. */
1122
+ readonly limit?: number | undefined
1123
+ } | undefined
1124
+ ) => Effect.Effect<
1125
+ {
1126
+ readonly items: ReadonlyArray<FlowChildRecord>
1127
+ readonly cursor: string | undefined
1128
+ },
1129
+ JobStoreError
1130
+ >
1131
+
1132
+ /**
1133
+ * The flow sweeper's work list (see `FlowSweepWork`). `pendingAgeMs`
1134
+ * scopes reconciliation to rows whose eligibility timestamp is at least
1135
+ * this old (giving the push path time); `limit` bounds the rows returned
1136
+ * per class per sweep.
1137
+ *
1138
+ * Returning a row for reconciliation re-arms its eligibility timestamp
1139
+ * (it is not returned again until another `pendingAgeMs` elapses), so a
1140
+ * full page ROTATES across sweeps: healthy in-flight children and rows
1141
+ * this sweeper cannot act on never pin the head of the page and starve
1142
+ * the rows behind them.
1143
+ *
1144
+ * @since 0.6.0
1145
+ */
1146
+ readonly flowSweepWork: (options: {
1147
+ readonly pendingAgeMs: number
1148
+ readonly limit?: number | undefined
1149
+ }) => Effect.Effect<FlowSweepWork, JobStoreError>
1150
+
1151
+ /**
1152
+ * Mark dependency rows as `cascaded` after their cancels were delivered
1153
+ * into (or confirmed unnecessary by) the child's store. Idempotent.
1154
+ *
1155
+ * @since 0.6.0
1156
+ */
1157
+ readonly markChildrenCascaded: (
1158
+ flowId: JobId,
1159
+ childKeys: ReadonlyArray<string>
1160
+ ) => Effect.Effect<void, JobStoreError>
1161
+
841
1162
  readonly counts: (
842
1163
  queue?: QueueName
843
1164
  ) => Effect.Effect<Record<JobState, number>, JobStoreError>
844
1165
 
845
- /** Remove a job (and its ledger). Refuses (returns false) when active. */
1166
+ /**
1167
+ * Remove a job (and its ledger; a flow parent's dependency rows go with
1168
+ * it). Refuses (returns false) when active or `waiting-children`.
1169
+ *
1170
+ * Note the retention asymmetry for flows: AUTOMATIC pruning (`keep`
1171
+ * policies, the `historyTtl` sweep) must skip a settled flow parent whose
1172
+ * rows still owe cascade cancels (`cancelled` and not `cascaded`) — those
1173
+ * rows are the only record that real cancels are still due in the child
1174
+ * stores. `remove` is the explicit operator override and deletes anyway.
1175
+ */
846
1176
  readonly remove: (id: JobId) => Effect.Effect<boolean, JobStoreError>
847
1177
  }
848
1178