npm.io
0.2.0 • Published 2d ago

zenerator

Licence
Version
0.2.0
Deps
0
Size
30 kB
Vulns
0
Weekly
0

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)
})

Keywords