···11/**
22- * Task Queue - with purus
22+ * Task Queue Example - Concurrency and Dependency Injection
33+ * ==========================================================
44+ *
55+ * This example teaches three advanced purus concepts:
66+ *
77+ * 1. REAL CANCELLATION - When timeout() fires, the job actually stops
88+ * Promise.race just ignores the loser - it keeps running in the background.
99+ * purus calls the cleanup function, which clears the timer and sets cancelled=true.
1010+ *
1111+ * 2. DEPENDENCY INJECTION - provide() and accessEff() for testability
1212+ * The QueueEnv type defines what dependencies the effect needs.
1313+ * provide(prodEnv) supplies production logger, provide(testEnv) supplies silent one.
1414+ * No DI framework needed - it's just functions and types.
315 *
44- * Same job processor, but:
55- * - timeout() actually cancels the job
66- * - Dependencies are injected (easy to test)
77- * - Errors are typed, not unknown
1616+ * 3. TYPED ERRORS THROUGH THE PIPELINE - No error information lost
1717+ * JobError is a union type. Every handler knows exactly what can fail.
1818+ * Compare with without-purus.ts where errors become `unknown`.
1919+ *
2020+ * Compare with without-purus.ts to see:
2121+ * - Promise.race that doesn't actually cancel (line 41-48)
2222+ * - Hardcoded logger that's awkward to mock (line 19-22)
2323+ * - Lost error types through the pipeline
2424+ *
2525+ * Prerequisites: http-client and workflow-engine examples
2626+ * This is the most advanced example - it combines effects, DI, and concurrency.
827 */
9281029import {
···2544 runPromise,
2645} from "../../src/index"
27462828-// -----------------------------------------------------------------------------
2929-// Job types
3030-// -----------------------------------------------------------------------------
4747+// =============================================================================
4848+// SECTION 1: Types
4949+// =============================================================================
5050+//
5151+// Job: The unit of work to process
5252+// JobError: What can go wrong (typed, not `unknown`)
5353+// QueueEnv: Dependencies that the effect requires (injected at runtime)
5454+// =============================================================================
31553256type Job = {
3357 id: string
···3559 payload: Record<string, unknown>
3660}
37616262+// Typed errors - we know exactly what can fail
3863type JobError =
3964 | { readonly _tag: "TransientError"; readonly jobId: string; readonly message: string }
4065 | { readonly _tag: "TimeoutError"; readonly jobId: string; readonly ms: number }
···4671 ({ _tag: "TimeoutError", jobId, ms }),
4772}
48734949-// Environment - swap this out in tests
7474+// =============================================================================
7575+// SECTION 2: Dependency Injection with Environments
7676+// =============================================================================
7777+//
7878+// THE PROBLEM WITH HARDCODED DEPENDENCIES:
7979+// In without-purus.ts, the logger is a module-level constant.
8080+// To test silently, you'd need to mock the module or set NODE_ENV.
8181+//
8282+// THE SOLUTION - ENVIRONMENT TYPES:
8383+// 1. Define an interface (QueueEnv) that describes what the effect needs
8484+// 2. Use accessEff() to read from the environment
8585+// 3. Use provide() to supply the actual implementation
8686+//
8787+// HOW IT WORKS:
8888+// - Eff<A, E, R> has an R type parameter - the "requirements"
8989+// - processJob returns Eff<void, never, QueueEnv> - it REQUIRES QueueEnv
9090+// - provide(prodEnv) supplies QueueEnv, changing R from QueueEnv to unknown
9191+//
9292+// TESTING: Just swap provide(prodEnv) with provide(testEnv) in tests.
9393+// =============================================================================
9494+9595+// Environment type - what the effect requires
5096type QueueEnv = {
5197 logger: {
5298 info: (msg: string) => void
···54100 }
55101}
561025757-// -----------------------------------------------------------------------------
5858-// Job execution - with real cancellation
5959-// -----------------------------------------------------------------------------
103103+// Production environment - logs to console
104104+const prodEnv: QueueEnv = {
105105+ logger: {
106106+ info: (msg) => console.log(`[INFO] ${msg}`),
107107+ error: (msg) => console.log(`[ERROR] ${msg}`),
108108+ },
109109+}
110110+111111+// Test environment - silent logging for unit tests
112112+// Just swap provide(prodEnv) with provide(testEnv) in tests
113113+const _testEnv: QueueEnv = {
114114+ logger: {
115115+ info: () => {},
116116+ error: () => {},
117117+ },
118118+}
119119+120120+// =============================================================================
121121+// SECTION 3: Real Cancellation with async()
122122+// =============================================================================
123123+//
124124+// THE PROBLEM WITH Promise.race:
125125+// In without-purus.ts (line 41-48), Promise.race returns when one settles.
126126+// But THE LOSING PROMISE KEEPS RUNNING! If the job takes 10 seconds and
127127+// timeout is 5 seconds, the job runs for 10 seconds anyway - we just ignore it.
128128+//
129129+// THE SOLUTION - CLEANUP FUNCTIONS:
130130+// async() takes a register function that returns a cleanup function.
131131+// When timeout() fires (or manual interrupt), the cleanup function runs.
132132+//
133133+// HOW IT WORKS:
134134+// 1. Register function starts async work
135135+// 2. Returns cleanup function (clears timer, sets cancelled flag)
136136+// 3. On timeout: cleanup runs -> timer cleared -> no resume called
137137+// 4. The job actually STOPS, not just ignored
138138+//
139139+// GOTCHA: Check the cancelled flag before calling resume.
140140+// If cleanup ran, the fiber is done - calling resume causes problems.
141141+// =============================================================================
6014261143const executeJob = (job: Job): Eff<void, JobError, QueueEnv> =>
62144 async((resume) => {
145145+ // IMPORTANT: This flag tracks whether we've been cancelled
146146+ // If true, we must NOT call resume - the fiber is already done
63147 let cancelled = false
148148+64149 const delay = Math.random() * 200 + 50
6515066151 const timeoutId = setTimeout(() => {
6767- if (cancelled) return // Already cancelled, don't resume
152152+ // CHECK BEFORE RESUME: If cancelled, the fiber is done
153153+ // Calling resume after cancellation causes undefined behavior
154154+ if (cancelled) return
68155156156+ // 30% chance of failure for demo purposes
69157 if (Math.random() < 0.3) {
70158 resume(Exit.fail(JobError.transient(job.id, "transient error")))
71159 } else {
···73161 }
74162 }, delay)
751637676- // Return cleanup - called on timeout or interrupt
164164+ // CLEANUP FUNCTION: Called on timeout, interrupt, or race-loser
165165+ // This is the key difference from Promise.race - we actually clean up
77166 return () => {
7878- cancelled = true
7979- clearTimeout(timeoutId)
167167+ cancelled = true // Signal that we're done
168168+ clearTimeout(timeoutId) // Cancel the pending timer
80169 }
81170 })
821718383-// -----------------------------------------------------------------------------
8484-// Job pipeline
8585-// -----------------------------------------------------------------------------
172172+// =============================================================================
173173+// SECTION 4: Job Pipeline
174174+// =============================================================================
175175+//
176176+// COMPOSABILITY IN ACTION:
177177+// The pipeline is built from simple, reusable combinators:
178178+// - accessEff() reads the environment for logging
179179+// - retry() wraps with retry logic
180180+// - timeout() adds cancellation timeout
181181+// - catchAll() recovers from errors
182182+//
183183+// Each piece is independent. Want more retries? Change one number.
184184+// Want a longer timeout? Change one number. No restructuring needed.
185185+// =============================================================================
8618687187const TIMEOUT_MS = 5000
88188const MAX_RETRIES = 3
8918990190const processJob = (job: Job): Eff<void, never, QueueEnv> =>
91191 pipe(
192192+ // accessEff gets the environment and runs an effect with it
92193 accessEff((env: QueueEnv) =>
93194 pipe(
94195 succeed(undefined),
···98199 })
99200 )
100201 ),
202202+203203+ // Retry up to MAX_RETRIES times on failure
101204 retry(MAX_RETRIES),
205205+206206+ // Timeout after TIMEOUT_MS - ACTUALLY CANCELS (unlike Promise.race)
102207 timeout(TIMEOUT_MS),
208208+209209+ // Convert timeout (null) to typed error
103210 flatMap((result) =>
104211 result === null
105212 ? fail(JobError.timeout(job.id, TIMEOUT_MS))
106213 : succeed(result)
107214 ),
215215+216216+ // Error recovery - log and continue
217217+ // Note: error is JobError, not unknown - we know exactly what failed
108218 catchAll((error: JobError) =>
109219 accessEff((env: QueueEnv) => {
110220 const msg = error._tag === "TimeoutError"
···114224 return succeed(undefined)
115225 })
116226 ),
227227+228228+ // Final log
117229 flatMap(() =>
118230 accessEff((env: QueueEnv) => {
119231 env.logger.info(`Job ${job.id} completed`)
···122234 )
123235 )
124236125125-// -----------------------------------------------------------------------------
126126-// Queue processing
127127-// -----------------------------------------------------------------------------
237237+// =============================================================================
238238+// SECTION 5: Queue Processing
239239+// =============================================================================
240240+//
241241+// allSequential processes jobs one at a time.
242242+// For parallel processing, use `all()` instead.
243243+// =============================================================================
128244129245const processQueue = (jobs: Job[]): Eff<void, never, QueueEnv> =>
130246 pipe(
···132248 mapEff(() => undefined)
133249 )
134250135135-// -----------------------------------------------------------------------------
136136-// Environments
137137-// -----------------------------------------------------------------------------
138138-139139-const prodEnv: QueueEnv = {
140140- logger: {
141141- info: (msg) => console.log(`[INFO] ${msg}`),
142142- error: (msg) => console.log(`[ERROR] ${msg}`),
143143- },
144144-}
145145-146146-// For tests - just swap provide(prodEnv) with provide(testEnv)
147147-const _testEnv: QueueEnv = {
148148- logger: {
149149- info: () => {},
150150- error: () => {},
151151- },
152152-}
153153-154154-// -----------------------------------------------------------------------------
155155-// Demo
156156-// -----------------------------------------------------------------------------
251251+// =============================================================================
252252+// SECTION 6: Demo
253253+// =============================================================================
254254+//
255255+// NOTICE:
256256+// - provide(prodEnv) injects production logger into the entire pipeline
257257+// - For tests, you'd use provide(testEnv) for silent logging
258258+// - No DI framework, no mocking library - just functions and types
259259+// =============================================================================
157260158261const main = async () => {
159262 console.log("=== Task Queue (with purus) ===\n")
···164267 { id: "job-3", type: "sync", payload: { source: "db", target: "cache" } },
165268 ]
166269270270+ // provide() injects the environment into the effect
271271+ // Swap prodEnv with testEnv for unit tests - no code changes needed
167272 const program = pipe(
168273 processQueue(jobs),
169169- provide(prodEnv)
274274+ provide(prodEnv) // <- Change to testEnv in tests
170275 )
171276172277 await runPromise(program)
+99-25
examples/task-queue/without-purus.ts
···11/**
22- * Task Queue - vanilla TypeScript
22+ * Task Queue - Vanilla TypeScript (Comparison)
33+ * =============================================
44+ *
55+ * This is the "before" version. Compare with with-purus.ts to see how purus
66+ * improves concurrency and testability.
77+ *
88+ * PROBLEMS THIS APPROACH HAS:
99+ *
1010+ * 1. Promise.race DOESN'T CANCEL - It just ignores the loser
1111+ * See executeWithTimeout() on line 48. When timeout wins, the job
1212+ * KEEPS RUNNING in the background. We're not cancelling, just ignoring.
1313+ * This wastes resources and can cause side effects after "timeout".
1414+ *
1515+ * 2. HARDCODED LOGGER - Awkward to mock in tests
1616+ * The logger is a module-level constant (line 21-24).
1717+ * To test silently, you'd need jest.mock() or process.env checks.
1818+ * Compare with-purus.ts where you just swap provide(prodEnv) for provide(testEnv).
1919+ *
2020+ * 3. ERRORS BECOME UNKNOWN - Type information is lost
2121+ * lastError is `unknown`. We throw it, catch it, and lose all structure.
2222+ * In with-purus.ts, JobError flows through typed - no guessing.
2323+ *
2424+ * THE KEY INSIGHT: Promise.race is not cancellation.
2525+ * Run both examples and watch the logs - see how purus actually stops work.
326 *
44- * A simple background job processor. Note how Promise.race
55- * doesn't actually cancel the losing promise.
2727+ * Prerequisites: http-client and workflow-engine examples
2828+ * Next: Compare with with-purus.ts
629 */
73088-// -----------------------------------------------------------------------------
99-// Job types
1010-// -----------------------------------------------------------------------------
1111-1231type Job = {
1332 id: string
1433 type: "email" | "image" | "sync"
1534 payload: Record<string, unknown>
1635}
17361818-// Hardcoded logger - awkward to mock in tests
3737+// =============================================================================
3838+// SECTION 1: Hardcoded Logger (The Problem)
3939+// =============================================================================
4040+//
4141+// This logger is a module-level constant.
4242+// In tests, you'd typically need:
4343+// - jest.mock() to replace it
4444+// - process.env.NODE_ENV checks to disable logging
4545+// - A mocking library
4646+//
4747+// Compare with-purus.ts where you just swap provide(prodEnv) with provide(testEnv).
4848+// =============================================================================
4949+1950const logger = {
2051 info: (msg: string) => console.log(`[INFO] ${msg}`),
2152 error: (msg: string) => console.log(`[ERROR] ${msg}`),
2253}
23542424-// -----------------------------------------------------------------------------
2525-// Job execution
2626-// -----------------------------------------------------------------------------
5555+// =============================================================================
5656+// SECTION 2: Job Execution
5757+// =============================================================================
5858+//
5959+// A simple async function that simulates work.
6060+// No cleanup mechanism - when this starts, it runs to completion.
6161+// =============================================================================
27622863const executeJob = async (job: Job): Promise<void> => {
2964 const delay = Math.random() * 200 + 50
···3772 logger.info(`Job ${job.id} (${job.type}) completed`)
3873}
39747575+// =============================================================================
7676+// SECTION 3: Promise.race Timeout (The Problem)
7777+// =============================================================================
7878+//
7979+// !!! THIS IS THE KEY PROBLEM !!!
8080+//
8181+// Promise.race() returns when ONE promise settles.
8282+// BUT THE OTHER PROMISE KEEPS RUNNING!
8383+//
8484+// If executeJob takes 10 seconds and timeout is 5 seconds:
8585+// - Promise.race returns after 5 seconds with "Timeout"
8686+// - executeJob continues running for 5 more seconds
8787+// - Any side effects from executeJob still happen
8888+// - We've used resources for work we're "ignoring"
8989+//
9090+// In with-purus.ts, timeout() calls the cleanup function, which sets
9191+// cancelled=true and clearTimeout(). The job actually STOPS.
9292+// =============================================================================
9393+4094const executeWithTimeout = async (job: Job, timeoutMs: number): Promise<void> => {
4141- // Promise.race returns when one settles, but the loser keeps running!
4242- // We're just ignoring the result, not actually cancelling the job.
9595+ // Promise.race: Returns when first promise settles, but...
9696+ // THE LOSER KEEPS RUNNING IN THE BACKGROUND!
9797+ //
9898+ // If executeJob takes 10s and timeout is 5s:
9999+ // - We get "Timeout" after 5s
100100+ // - But executeJob runs for the full 10s anyway
101101+ // - Any side effects still happen
43102 const result = await Promise.race([
44103 executeJob(job),
45104 new Promise<never>((_, reject) =>
···49108 return result
50109}
511105252-// -----------------------------------------------------------------------------
5353-// Retry logic
5454-// -----------------------------------------------------------------------------
111111+// =============================================================================
112112+// SECTION 4: Retry Logic
113113+// =============================================================================
114114+//
115115+// Standard retry loop with error accumulation.
116116+//
117117+// NOTICE: lastError is `unknown`. We've lost all type information about
118118+// what went wrong. The caller has to guess and instanceof-check.
119119+// =============================================================================
5512056121const executeWithRetry = async (
57122 job: Job,
58123 maxRetries: number,
59124 timeoutMs: number
60125): Promise<void> => {
6161- let lastError: unknown
126126+ let lastError: unknown // <- All error type information is lost here
6212763128 for (let attempt = 1; attempt <= maxRetries; attempt++) {
64129 try {
···66131 await executeWithTimeout(job, timeoutMs)
67132 return
68133 } catch (e) {
6969- lastError = e
134134+ lastError = e // <- e is unknown, we've lost all structure
70135 logger.error(`Attempt ${attempt} failed: ${e instanceof Error ? e.message : e}`)
71136 }
72137 }
731387474- throw lastError // Caller gets unknown
139139+ throw lastError // <- Caller gets unknown, not a typed error
75140}
761417777-// -----------------------------------------------------------------------------
7878-// Queue processing
7979-// -----------------------------------------------------------------------------
142142+// =============================================================================
143143+// SECTION 5: Queue Processing
144144+// =============================================================================
145145+//
146146+// Processes jobs sequentially with error recovery.
147147+// Note how we track success/failure but can't distinguish error types.
148148+// =============================================================================
8014981150const processQueue = async (jobs: Job[]): Promise<void> => {
82151 const results: Array<{ job: Job; success: boolean; error?: unknown }> = []
···86155 await executeWithRetry(job, 3, 5000)
87156 results.push({ job, success: true })
88157 } catch (e) {
158158+ // e is unknown - we can't distinguish timeout vs transient vs permanent
89159 results.push({ job, success: false, error: e })
90160 }
91161 }
···95165 logger.info(`Queue complete: ${succeeded} succeeded, ${failed} failed`)
96166}
971679898-// -----------------------------------------------------------------------------
9999-// Demo
100100-// -----------------------------------------------------------------------------
168168+// =============================================================================
169169+// SECTION 6: Demo
170170+// =============================================================================
171171+//
172172+// Run this and compare with with-purus.ts.
173173+// Notice how there's no way to swap out the logger for testing.
174174+// =============================================================================
101175102176const main = async () => {
103177 console.log("=== Task Queue (without purus) ===\n")