Migrating from 0.6.x to 0.7.0
What changed between Routecraft 0.6.0 and 0.7.0, and how to update.
0.7.0 is the pre-v1 architecture release: the contracts that freeze at v1 changed shape once, now, so they do not have to change after. In exchange every route runs ~25% faster (steps wrapped with .error() ~45% faster) and event throughput doubles.
Four consumer-visible changes:
- Event names are a fixed set; identity moved into the payload.
route:<id>:exchange:failedbecomesroute:exchange:failedwithrouteIdindetails. Wildcard subscriptions are replaced by exact names, the"*"catch-all, and theforRoute()filter helper. - Source adapters receive one
Subscriptionobject. The positionalsubscribe(context, handler, abortController, onReady?, meta?)signature is gone..from()additionally accepts async generator functions and iterables. - Custom
Stepimplementations return aStepOutcome. Steps no longer receive the engine queue; the executor owns scheduling. Custom aggregators return{ body, headers? }instead of a fabricatedExchange. @routecraft/aierror codes are renamed.RC5025/RC5026/RC5027becomeAI1001/AI1002/AI1003; ecosystem packages now register their own namespaced codes viaregisterErrorCodes().
Routes built only from the DSL (craft().from(...).transform(...).to(...)) with framework adapters need changes only if they subscribe to events (change 1). Changes 2-4 affect adapter authors and advanced integrations.
Two behavioural notes that are not API changes: context store seeding for cron/direct/mail config now happens in initPlugins() (called automatically by start()) instead of the CraftContext constructor, and mail/carddav client managers now drain in reverse-plugin-order teardown.
1. Events: fixed names, identity in the payload
Every hierarchical event name loses its identity segment. The payload already carried routeId (and now always does), so subscriptions become exact names plus payload filtering.
| 0.6.x name | 0.7.0 name |
|---|---|
route:<id>:registered / :starting / :started / :stopping / :stopped | route:registered / route:starting / route:started / route:stopping / route:stopped |
route:<id>:error / route:<id>:error:caught | route:error / route:error:caught |
route:<id>:exchange:started / :completed / :failed / :dropped / :restored | route:exchange:started / :completed / :failed / :dropped / :restored |
route:<id>:step:started / :completed / :failed | route:step:started / :completed / :failed |
route:<id>:step:<label>:error | route:step:error (step label is details.operation) |
route:<id>:batch:started / :flushed / :stopped | route:batch:started / :flushed / :stopped |
route:<id>:error-handler:invoked / :recovered / :failed | route:error-handler:invoked / :recovered / :failed |
route:<id>:cache:hit / :miss / :stored / :failed | route:cache:hit / :miss / :stored / :failed |
route:<id>:operation:choice:matched / :unmatched | route:operation:choice:matched / :unmatched |
route:<id>:agent:* (all agent events) | route:agent:* (same suffixes) |
plugin:<pluginId>:registered / :starting / :started / :stopping / :stopped | plugin:registered / ... (pluginId in payload) |
context:*, auth:*, agent:registered, agent:tool:registered | unchanged |
Migrate by table lookup, not regex: several route ids contain words like batch or started, and a regex will corrupt names (route:my-batch:stopped must become route:stopped, but route:r1:batch:stopped must become route:batch:stopped).
Per-route subscriptions use the forRoute() helper (or filter on details.routeId):
// Before (0.6.x)
ctx.on('route:orders:exchange:failed', ({ details }) => alert(details.error))
// After (0.7.0)
import { forRoute } from '@routecraft/routecraft'
ctx.on('route:exchange:failed', forRoute('orders', ({ details }) => alert(details.error)))
Wildcard patterns are removed from ctx.on() / ctx.once(). The only pattern is the catch-all "*", which observes every event. Patterns like route:* or route:** now throw RC2001 with migration guidance.
// Before: ctx.on('route:*:exchange:*', handler) / ctx.on('**', handler)
ctx.on('*', (payload) => sink.write(payload._event, payload.details))
The event() source adapter keeps its pattern support (event('route:*') still works there); patterns match against the emitted name behind a single catch-all subscription.
Ecosystem events are declared by merging into EventDetailsMap (the same pattern as StoreRegistry):
declare module '@routecraft/routecraft' {
interface EventDetailsMap {
'plugin:myext:thing:happened': { routeId: string; thing: string }
}
}
2. Sources: the Subscription object
CallableSource collapses from five positional parameters to one object. Everything you had is still there under a named field, plus complete() replaces the abort-to-finish idiom:
// Before (0.6.x)
async subscribe(context, handler, abortController, onReady) {
onReady?.()
while (!abortController.signal.aborted) {
await handler(await poll(), { 'x-origin': 'poll' })
}
abortController.abort() // finite source done
}
// After (0.7.0)
async subscribe(sub: Subscription<T>) {
sub.ready()
while (!sub.signal.aborted) {
await sub.emit({ message: await poll(), headers: { 'x-origin': 'poll' } })
}
sub.complete() // finite source done
}
Field map: context -> sub.context, handler(msg, headers, parse, parseFailureMode) -> sub.emit({ message, headers, parse, parseFailureMode }), abortController.signal -> sub.signal, abortController.abort() -> sub.complete(reason?), onReady?.() -> sub.ready(), meta -> sub.meta (now always present).
New since the same release, built on this contract:
// Generator sources: each yield is one exchange
.from(async function* (sub) {
while (!sub.signal.aborted) yield await poll()
})
// Bare (async) iterables work too
.from(someAsyncIterable)
For driving a source directly in unit tests, @routecraft/testing adds testSubscription({ context, handler, abortController }).
3. Custom steps and aggregators
Step.execute no longer receives the remaining steps and the engine queue. Steps return what happened; the executor schedules:
// Before (0.6.x)
async execute(exchange, remainingSteps, queue) {
const next = DefaultExchange.rewrap(exchange, { body: transform(exchange.body) })
queue.push({ exchange: next, steps: remainingSteps })
}
// After (0.7.0)
async execute(exchange: Exchange): Promise<StepOutcome> {
const next = DefaultExchange.rewrap(exchange, { body: transform(exchange.body) })
return { kind: 'continue', exchange: next }
}
Outcomes: continue (run remaining steps), complete (skip remaining steps, success), drop (halted; emit your drop events and markDropped first), branch (prepend steps, then remaining), fanOut (schedule each child). Join-style steps consume pending siblings via the StepContext second argument (ctx.takePending(predicate)).
Wrapper authors (WrapperStep subclasses): runInner(exchange, ctx) now returns the inner's StepOutcome and there is no innerQueue buffer to manage; recovery returns a substitute outcome.
Custom aggregators return the combined body (plus optional headers) instead of a fake exchange:
// Before: return { ...exchanges[0], body: merged } as Exchange
// After:
.aggregate((exchanges) => ({ body: merge(exchanges.map((e) => e.body)) }))
4. Error codes: AI namespace
@routecraft/ai's agent-block codes moved out of core and were renumbered:
| 0.6.x | 0.7.0 | Meaning |
|---|---|---|
RC5025 | AI1001 | Agent block resolution failed |
RC5026 | AI1002 | Agent block name collision |
RC5027 | AI1003 | Agent block misconfigured |
Update any code or alerting that matches on error.rc. Core RC#### codes are otherwise unchanged (one addition: RC1003, error-code registration failed).
Ecosystem packages can now own codes under a claimed namespace:
declare module '@routecraft/routecraft' {
interface ErrorCodeRegistry {
ACME1001: RCMeta
}
}
registerErrorCodes('ACME', { ACME1001: { ... } }, 'my-package')
Namespaces are claimable by exactly one package; RC is reserved for core; codes are the namespace plus four digits.