zenerator
Persistent generator workflows with deterministic replay, navigation, durable session context, and live runtime capabilities.
npm install zenerator
The state model
Zenerator keeps four kinds of state separate:
| Authoring construct | Lifecycle | Replay behavior |
|---|---|---|
| Generator locals | Internal execution state | Reconstructed by replay |
this.foo / session.ctx |
Durable session context | Saved, but never rewound by navigation |
capture(fn) |
Recorded snapshot | Returns the original recorded value |
env(key) |
Live external capability | Resolves the current provider again |
In shorthand:
generator locals → internal replayed execution state
this.foo → durable session context
capture(fn) → recorded snapshot
env(key) → live external capability
Context is for current draft data such as form answers. Environment is for services, clients, configuration, and other runtime dependencies that should remain outside serialized state.
Create and run a workflow
Wrap a generator with zen() and call start() to create a session:
import { zen } from 'zenerator'
const signup = zen(function* () {
const plan = yield 'Choose a plan'
const email = yield 'Enter your email'
return { plan, email }
})
const session = signup.start()
session.value
// 'Choose a plan'
session.next('pro')
session.value
// 'Enter your email'
session.next('ada@example.com')
session.value
// { plan: 'pro', email: 'ada@example.com' }
Generator arguments can be passed directly to start() and are restored with the workflow.
Durable session context
Inside a session's root generator, this is bound to session.ctx:
const profile = zen(function* () {
this.name = yield 'Name'
yield 'Review'
return { name: this.name }
})
const session = profile.start()
session.next('Ada')
session.ctx.name
// 'Ada'
Every session owns one stable, plain context object. Assigning session.ctx replaces its own contents in place, preserving object identity:
const reference = session.ctx
session.ctx = { name: 'Grace' }
session.ctx === reference
// true
Context values must be structured-cloneable. They are included in saved session state. Functions, API clients, sockets, and similar capabilities belong in the environment instead.
Mutate context before advancing
Hosts can update the stable context object directly before the generator resumes:
session.ctx.name = input.name
session.next(input)
The session.ctx setter remains available when complete replacement is useful. It clones the supplied snapshot, removes keys absent from it, and installs its contents without changing the stable context object's identity.
Live environment values
Use .provide(environment) to provide runtime dependencies and env(key) to read them during generator execution:
import { capture, env, zen } from 'zenerator'
const research = zen(function* () {
const ai = env('ai')
const startedAt = capture(() => new Date().toISOString())
yield { type: 'topic-form' }
const topic = this.topic
const brief = yield {
type: 'loader',
run: () => ai.generate(topic),
}
return { topic, brief, startedAt }
})
const session = research.provide({ ai }).start()
env() accepts string and symbol property keys. It can only be called during synchronous generator execution and throws if no active workflow or provider exists.
Provider frames retain their original objects. Both the provider and its values remain ordinarily mutable and live:
const runtime = {
ai: firstClient,
settings: { mode: 'safe' },
}
const configured = research.provide(runtime)
runtime.ai = secondClient
runtime.settings.mode = 'fast'
// Future env() calls see secondClient and mode === 'fast'.
Values returned by env() are the actual provider values, so workflows can mutate them normally when that capability is intentionally mutable:
const settings = env('settings')
settings.mode = 'fast'
Repeated calls add provider frames. The newest provider wins, while missing keys fall through to earlier frames:
const configured = workflow
.provide({ logger, ai: productionAi })
.provide({ ai: testAi })
A key explicitly provided with the value undefined still counts as provided and stops lookup at that frame.
Resolve a capability before creating asynchronous work. A later callback runs outside generator execution, so this is correct:
const ai = env('ai')
const topic = this.topic
yield loader(() => ai.generate(topic))
Calling env('ai') inside the loader callback would throw.
Nested workflows inherit the parent environment. A child can override selected keys for itself and its descendants without changing the parent scope:
const result = yield* child.provide({ ai: mockAi }).create()
// The parent still resolves its original ai provider here.
Recorded and live replay
capture() and context reads use recorded replay. env() uses live replay:
const workflow = zen(function* () {
const clock = env('clock')
const startedAt = capture(() => clock.now())
yield 'continue'
return {
startedAt, // original recorded value
currentRegion: env('region'), // current live provider
draftName: this.name, // recorded if read before the checkpoint
}
})
An env:get marker records the formatted key so replay can validate execution order, but the resolved value is never added to history. Replay calls the current provider again. Wrapping a value derived from the environment in capture() deliberately records that derived value instead.
If a live provider controls workflow structure, capture the decision so later replay remains deterministic:
const flags = env('flags')
const useNewFlow = capture(() => flags.useNewFlow)
if (useNewFlow) {
yield 'New flow'
}
Context reads that occurred before a restored checkpoint return their recorded values, reconstructing historical output. Historical root this assignments are ignored during replay so they cannot overwrite the latest durable context. After replay reaches the checkpoint, new reads see the current session.ctx.
Navigation and branching
Sessions maintain a checkpoint timeline:
session.back()
session.forward()
const location = session.location
session.back()
session.goTo(location)
Context represents the latest draft and does not rewind during back(), forward(), or goTo(). Going back and calling next() discards the old future and creates a new branch using the current context.
Form prefill and branch example
const contact = zen(function* () {
yield {
type: 'contact-form',
fields: ['name', 'email'],
}
yield {
type: 'review',
name: this.name,
email: this.email,
}
return {
name: this.name,
email: this.email,
}
})
const session = contact.start()
function submitContact(input) {
session.ctx.name = input.name
session.ctx.email = input.email
session.next(input)
}
submitContact({
name: 'Ada',
email: 'ada@example.com',
})
// Return to the form. The durable context remains available for prefill.
session.back()
renderContactForm(session.ctx)
// { name: 'Ada', email: 'ada@example.com' }
// Change an earlier answer while retaining the other saved default.
submitContact({
...session.ctx,
name: 'Grace',
})
session.value
// { type: 'review', name: 'Grace', email: 'ada@example.com' }
Save and resume
save() returns a version 2 state containing both the checkpoint timeline and a cloned context snapshot:
const saved = session.save()
// {
// version: 2,
// location: '2',
// sequence: 3,
// context: { name: 'Ada', focus: 'feature' },
// checkpoints: [...],
// }
const restored = contact.provide(environment).resume(saved)
The .provide() environment is never saved. Resuming the same state with a different provider makes env() return its new live values.
Version 1 states remain accepted and resume with an empty context. Every subsequent save() emits version 2.
v0.2 migration
In v0.1, .with() values were read through this. In v0.2, .provide() fully replaces .with(); there is no compatibility alias. Migrate external values to env():
// v0.1
const result = yield this.ai.generate(prompt)
const session = workflow.with({ ai }).start()
// v0.2
const ai = env('ai')
const result = yield ai.generate(prompt)
const session = workflow.provide({ ai }).start()
Use this only for durable, structured-cloneable session context. Hosts no longer need a separate context store or need to reinject form values after every resume:
const session = workflow.resume(record.state)
setPath(session.ctx, 'name', input.name)
session.next(input)
record.state = session.save()
record.current = publicView(session.value, session.ctx)
Nested workflows
Nested workflows composed with yield* read through their parent's context while keeping assignments in a local overlay:
const child = zen(function* () {
this.answer = yield 'Child answer'
return this.answer
})
const parent = zen(function* () {
this.answer = 'Parent answer'
const childAnswer = yield* child.create()
return {
childAnswer,
parentAnswer: this.answer,
}
})
Raw standalone iterators created with workflow.create() retain local context behavior. Durable externally visible context is a session feature.
Observe changes
Use onChange() to connect a session to a UI or host. The listener receives the current state immediately and after each navigation or advance:
const unsubscribe = session.onChange(state => {
// { value, done, location, canBack, canForward }
render(state.value, session.ctx)
})