Skip to content

Debugging your composition

You don’t see what happens inside a compose chain — what each Task returned, which were skipped, what your Tags carried, or what every Spot computed.

When something goes wrong, you want to see inside — without scattering console.log across every Task. There are a few ways to do that.

compose(...).run() returns Promise<Scope>await it to get a Scope with one method, scope.get(spot), that reads any Spot computed during the run. A Spot is any readable source in the composition: task.result, task.status, task.error, tag.value, anything from shape(...). The call returns undefined if the source wasn’t computed (a skipped or failed Task).

import { compose, createTask, createWire, tag } from "@app-compose/core"
const userId = tag<number>("userId")
const fetchUser = createTask({
name: "fetch-user",
run: { fn: () => ({ id: 1 }) },
})
const loadCart = createTask({
name: "load-cart",
run: {
context: userId.value,
fn: () => {
throw new Error("cart service is down")
},
},
})
;(async () => {
const scope = await compose()
.step(fetchUser)
.step(createWire({ from: fetchUser.result.id, to: userId }))
.step(loadCart)
.run()
console.log(`[fetchUser.status]: ${scope.get(fetchUser.status)}`)
console.log(`[fetchUser.result]: ${JSON.stringify(scope.get(fetchUser.result))}`)
console.log(`[userId]: ${scope.get(userId.value)}`)
console.log(`[loadCart.status]: ${scope.get(loadCart.status)}`)
console.log(`[loadCart.error]: ${scope.get(loadCart.error)}`)
})()

Good for

  • One place — all your reads cluster after the run.

Trade-offs

  • Manual — you pick what to read and log, by hand.
  • End-only — you see the final state of each Spot, not snapshots from mid-run.

Add a Task that reads any Tasks, Tags, or Spots via context and logs them. Place it between .step(...) calls to see state at that point in the chain.

The catch: every context read must be wrapped in optionaloptional(task.result), optional(tag.value), and so on. Without it, the debug Task is skipped along with any source that fails or is skipped — and you get no log.

debug from @app-compose/coda handles this wrapping for you. Pass any Tasks, Tags, or Spots; it returns a Task you can .step(...). A lint rule no-coda-debug flags every call so it doesn’t reach a commit.

import { debug } from "@app-compose/coda"
import { compose, createTask, createWire, tag } from "@app-compose/core"
const userId = tag<number>("userId")
const fetchUser = createTask({
name: "fetch-user",
run: { fn: () => ({ id: 1 }) },
})
const loadCart = createTask({
name: "load-cart",
run: {
fn: () => {
throw new Error("cart service is down")
},
},
})
compose()
.step(fetchUser)
.step(createWire({ from: fetchUser.result.id, to: userId }))
// after fetchUser + wire
.step(debug(fetchUser, userId))
.step(loadCart)
// after loadCart
.step(debug(loadCart))
.run()
console.warn("This sandbox flattens grouped logs.")
console.warn("In your DevTools console, debug() output is nested and collapsible.")