Skip to content

Scene-as-composer: builder step API, reusable modules, full Composer DSL on Scene#7

Open
kravetsone wants to merge 19 commits into
mainfrom
scene-as-composer
Open

Scene-as-composer: builder step API, reusable modules, full Composer DSL on Scene#7
kravetsone wants to merge 19 commits into
mainfrom
scene-as-composer

Conversation

@kravetsone
Copy link
Copy Markdown
Contributor

Summary

Redesign Scene as a first-class EventComposer. Each step becomes a sub-composer with lifecycle hooks plus the full bot DSL, and scenes can compose into one another via scene.extend(otherScene). Fully back-compat — every existing test (74) and existing API (step("message", handler), .ask(), .onEnter, enterSub/exitSub, …) keeps working unchanged.

// New: each step is a sub-composer
const confirm = new Scene().step("confirm", c => c
    .enter(ctx => ctx.send("Sure?", confirmKb))
    .callbackQuery("yes", ctx => ctx.scene.step.next())
    .callbackQuery("no",  ctx => ctx.scene.exit())
);

// Scenes compose: confirm's step inlines into checkout
const checkout = new Scene("checkout")
    .derive(async ctx => ({ user: await db.users.find(ctx.from!.id) }))
    .onEnter(ctx => analytics.track("checkout_start", ctx.user))
    .command("cancel", ctx => ctx.scene.exit())
    .step("review", c => c.message("Order looks good?").on("message", ctx =>
        ctx.scene.update({ ack: true })))
    .extend(confirm)
    .step("complete", c => c.message("Done! 🎉"));

What's new

  • Scene IS an EventComposer — full Composer DSL on every Scene (.command, .callbackQuery, .hears, .on, .use, .derive, .guard, .branch, .when, .extend, …). Scene-level handlers act as scene-global middleware (e.g. global /cancel from any step).
  • Builder step API.step(c => c…) and .step("name", c => c…). Each step is a StepComposer with lifecycle hooks (.enter, .exit, .fallback, .message, .events, .updates<T>()) plus the gramio surface.
  • Named steps + hybrid idsstep("intro", ...) for named, step(c => …) for numeric (autoincrement). step.id and step.previousId are now string | number. step.next/previous walks the array; step.go("name") jumps by name.
  • scene.extend(otherScene) — merge another Scene's steps + middleware into this one. Numeric ids renumber, named ids throw on collision. Enables reusable step modules.
  • Step modulesnew Scene() (no name) → `isModule = true`. `validateScenes` rejects direct registration. Use only via `scene.extend(module)`.
  • scene.onExit(handler) — symmetric to onEnter; fires on `exit` / `exitSub` / `reenter`.
  • .derive() visible in .onEnter — derives apply before onEnter so `ctx.user` etc. work in onEnter without workarounds. Distinguished from `step.go()` transitions via a new `entered` flag in storage.
  • ctx.scene.update({...}) auto-advances builder steps — walks `~scene.steps` array by index, supporting named ids.

Architecture

  • `src/scene-composer.ts` — `createComposer` instance with gramio's `_composerMethods` table → `SceneComposerBase`.
  • `src/step-composer.ts` — `createComposer` with gramio methods + step lifecycle (`.enter/.exit/.fallback/.message/.events/.updates`).
  • `src/scene.ts` — `Scene` extends `SceneComposerBase`. Scene-specific data on `this["scene"]` (separate from composer's ``). Internal runtime methods renamed to `dispatch`/`dispatchActive` to avoid collision with inherited `Composer.compose/run`.
  • `src/scene-internals.ts` — shared types for the `~scene` slot.
  • `src/types.ts` — `stepId: string | number` (was `number`); new `entered?: boolean` field.
  • `src/utils.ts` — `getStepDerives` walks `~scene.steps` for next/previous; `update()` defaults to advance-by-index in builder mode; `validateScenes` rejects modules.

Bugs found & fixed via testing

  1. `firstTime` path was skipping scene-level `.derive()` / `.decorate()` — fixed by running setup chain first.
  2. `dispatchActive` fallback logic was inverted; terminal (passthrough) fired before fallback could check — rewrote routing as clean if/else.
  3. Guard middleware was skipped on firstTime path — added `guard` to setup whitelist; failing guard now blocks step entry.
  4. `update({})` without options didn't advance named steps — fallback to walk-array-by-index.

Migration notes

  • `stepId: number` → `stepId: string | number` in storage. Existing numeric records remain valid (subset). No data migration needed.
  • New optional `entered?: boolean` field. Old data treated as `false` — correct default.
  • `gramio` peer-dep bumped from `>=0.7.0` to `>=0.9.0` (when `_composerMethods` was exported).
  • Internal `scene.compose(ctx, onNext, passthrough)` and `scene.run(ctx, storage, key, data, passthrough)` were renamed to `scene.dispatch` / `scene.dispatchActive`. These were only ever called from the package internals (`utils.ts`, `index.ts`); the inherited `Composer.compose()` / `Composer.run(ctx, next?)` are unchanged for users who call them.

Tests

  • 132/132 tests across 12 files, all green.
  • 58 new tests in 7 new files cover the new API (builder, navigation, lifecycle, extend(scene), onExit, update semantics, full Composer DSL on Scene, complex realistic flows).
  • All 74 existing tests (legacy API) keep passing without changes.

Test plan

  • `bun test` (132/132 passing)
  • `bun tsc --noEmit` (clean)
  • `bun biome check` (clean)
  • Manual exercise of new API in an example bot

kravetsone added 19 commits May 8, 2026 15:43
Step 1 of the scene-as-composer redesign. Forward-compatible storage
shape so named steps (.step("intro", c => ...)) can land alongside the
numeric default. Existing numeric records remain valid — `string | number`
is a superset.

next()/previous() throw on string ids for now; sceneSteps array walking
arrives in step 6.
Step 3 of scene-as-composer. Standalone types module describing
per-step records and per-Scene scene-specific state (steps array,
stepsCount, enter/exit hooks, isModule flag, type-only params/state/
exitData carriers).

These types live on Scene's own `~scene` slot rather than augmenting
@gramio/composer's `~` slot — the latter is an inline class field type
and not amenable to declare-module augmentation. Keeping our state on a
separate slot also avoids polluting composer internals for non-Scene
composers.
Step 4 of scene-as-composer. StepComposer is the per-step sub-composer
exposed to `.step(c => c…)` builders. Built via createComposer with the
gramio _composerMethods table (.command/.callbackQuery/.hears/.on/etc.)
plus step-only lifecycle hooks:

  .enter(handler)     — runs on firstTime entry; replaces firstTime check
  .exit(handler)      — runs when leaving the step
  .fallback(handler)  — catch-all for unmatched events
  .message(text|fn)   — sugar over .enter(ctx => ctx.send(text))
  .events([...])      — narrow event whitelist (default: message|callback_query)
  .updates<T>()       — type-only opt-in for state inference

Storage: hooks land on this["~step"] so they don't collide with
@gramio/composer's own ~ slot. Read by Scene runtime via
getStepInternals() / buildStepEntry().

Also bumps gramio peerDependency to >=0.9.0 (when _composerMethods was
exported) and the dev dependency to ^0.9.0.

No runtime consumers yet — StepComposer wires into Scene in step 6.
Step 5 of scene-as-composer. The crown jewel of the refactor.

Scene now extends SceneComposerBase (a createComposer-produced class
seeded with the gramio _composerMethods table), so every Scene instance
has the full bot-level DSL out of the box:

  • base composer: .use/.on/.derive/.decorate/.guard/.branch/.route/
    .fork/.tap/.lazy/.group/.extend/.when/.as/.onError/.error/.macro
  • gramio sugar: .command/.callbackQuery/.hears/.reaction/.inlineQuery/
    .chosenInlineResult/.startParameter

Scene-specific data lives on a dedicated `~scene` slot (steps array,
stepsCount, lifecycle hooks, isModule flag, type-only carriers for
params/state/exitData). The composer's own `~` slot stays untouched —
no augmentation of @gramio/composer needed.

Internal renames to dodge inherited-method signature clash:
  • Scene.compose(ctx, onNext, passthrough)  →  Scene.dispatch(...)
  • Scene.run(ctx, storage, key, data, ...)  →  Scene.dispatchActive(...)

The inherited Composer.compose() / Composer.run(ctx, next?) remain
available unchanged for callers that want the middleware-runner shape.

utils.ts and index.ts updated to call the renamed entry points. No
external API breaks: scene.run()'s 5-arg form was only invoked from
within the package.

74/74 existing tests pass. The Modify<...> chain on params/state/
exitData/step/ask/extend continues to flow through correctly because
the composer's own generic slots are independent and we kept Scene's
4-slot generic system intact.
Step 6 of scene-as-composer. The user-facing payoff: each step is now a
sub-composer with its own .enter/.exit/.fallback/.message lifecycle plus
the full gramio surface (.on/.command/.callbackQuery/.hears/...).

API surface added:

  scene.step(c => c.enter(ctx => ...).on("message", ...))   // numeric, autoincrement
  scene.step("intro", c => c.enter(...).on(...))             // named step

Disambiguation from the legacy `.step(eventName, handler)` form: if the
first string arg is a known Telegram update name (from utils.ts:events),
it's treated as a legacy event filter; otherwise as a step name. Arrays
remain unambiguously legacy. No source-level breaks for existing tests.

Storage: builder steps live on `~scene.steps` (array of SceneStepEntry).
Legacy gated `.use()` middleware steps continue to live on the composer
chain. Mixing the two in one scene works.

Runtime (dispatchActive):
  - Find current step in ~scene.steps. If absent → legacy mode (whole
    composer chain).
  - firstTime: run entry's .message (sugar) then .enter, set firstTime=false.
  - Subsequent updates: scene-level chain (this["~"].middlewares with
    cross-bot dedup) → step composer chain → fallback. So a scene-level
    .command("cancel") works as an escape hatch from any builder step.

Step navigation (utils.ts):
  - next/previous walks ~scene.steps by index, supporting named ids.
  - go(string) jumps to a named step.
  - getSceneEnter / getSceneEnterSub seed initial stepId from the first
    step's id (string for named-first-step scenes, 0 for legacy-numeric).
  - getSceneEnter now dispatches via scene.dispatchActive so the
    builder-step's .enter fires on entry.

Tests: 7 new smoke tests in tests/builder-smoke.test.ts cover .enter,
.message, named navigation, mixed legacy+builder, scene-level .command
escape hatch, named-step collision detection, sequential numeric ids.
All 81/81 tests green.
Steps 9 + 10 of scene-as-composer. Two complementary features that
together unlock reusable, composable scene flows.

scene.extend(otherScene) merges:
  • composer middlewares / derives / macros / errors (via super.extend)
  • ~scene.steps array — numeric ids renumbered, named ids checked for
    collision (throws on duplicate)
  • lifecycle hooks (.onEnter / .onExit) — A wins; B's copied only when A
    has none

The plugin / EventComposer paths skip the step-merge branch by checking
`"~scene" in other` — non-Scene arguments flow through super.extend()
unchanged.

Step modules (unnamed Scene):
  • new Scene() with no name → ~scene.isModule = true
  • validateScenes() throws if a module is registered in scenes([...])
  • intended use: define reusable step blocks (confirm, collect-contact,
    auth, etc.) and .extend() them into named scenes

  // Module: cannot be entered directly
  const confirm = new Scene().step("confirm", c => c
      .enter(ctx => ctx.send("Are you sure?"))
      .callbackQuery("yes", ctx => ctx.scene.step.next())
      .callbackQuery("no",  ctx => ctx.scene.exit())
  );

  // Compose into a named scene
  const checkout = new Scene("checkout")
      .step("review", c => c.enter(...).on("message", ...))
      .extend(confirm)
      .step("complete", c => c.enter(...));

Bug fix uncovered while writing tests: dispatchActive's firstTime path
was skipping the scene chain entirely, so .derive() / .decorate()
results never reached the builder step's .enter handler. Fixed by
running only ctx-mutating middleware (derive/decorate) from both the
scene chain and the step's own chain on first entry — regular handlers
(.use/.on/.command/...) deliberately skipped because there's no incoming
event to match on entry.

Tests: 5 new in tests/extend-scene.test.ts cover named-step merge,
numeric renumbering, named-step collision throw, module-registration
rejection, and plugin .extend() path still working with derive flow-
through. 86/86 tests green.
Step 8 of scene-as-composer. Symmetric to scene.onEnter — fires when the
user leaves the scene, before the scene's storage is torn down. Useful
for cleanup, analytics, "thanks for completing" messages.

Triggers:
  • ctx.scene.exit()           — fires onExit, then deletes storage
  • ctx.scene.exitSub()        — fires onExit on the sub-scene leaving,
                                 then merges returnData into parent
  • ctx.scene.reenter()        — fires onExit on the prior occupancy,
                                 then runs the normal scene-enter flow

Also forwarded by scene.extend(otherScene): onExit is copied from the
extended Scene only when the target Scene has none (A wins).

Implementation:
  • getSceneExit / getSceneExitSub now receive `context` and the
    relevant Scene reference, awaiting `scene["~scene"]?.exit?.(ctx)`
    before storage cleanup.
  • reenter wraps the existing getSceneEnter call in a small async
    handler that fires onExit first.

Tests: 4 in tests/onexit.test.ts cover all three triggers + the
extend(scene) copy semantics. 90/90 tests green.
ctx.scene.update({...}) without options now correctly advances to the
next step in the `~scene.steps` array, supporting named step ids:

  scene.step("email", c => c.message("Email?").on("message", ctx => {
      ctx.scene.update({ email: ctx.text });   // ← advances to next builder step
  }));

Before: only numeric arithmetic (`stepId + 1`) was used as the default,
which silently no-op'd for string ids — the user got stuck on the same
step after calling update().

Resolution rules in order:
  1. Explicit options.step → go to it
  2. Explicit options without step → persist state only
  3. No options + builder mode (sceneSteps populated) → walk array index
  4. No options + numeric stepId → stepId + 1 (legacy)
  5. Otherwise → persist state only

Also documents in scene.ts why .ask() is intentionally still wired as a
legacy numeric step (Step 11 of the redesign deferred): migrating it to
a named-step builder breaks chains where .ask() is followed by a legacy
.step("message", ...) — the transition system can't bridge between the
two ordering schemes yet. Cleaner unification will land in v0.7.x.

90/90 tests green.
Step 12 (partial). Adds 30 new tests across 4 files covering the new
API surface end-to-end. Two real bugs surfaced and fixed along the way.

Test files
  • tests/update-semantics.test.ts (7) — ctx.scene.update() auto-advance
    semantics, including the showcase pattern: update({ack:true}) bridges
    across .extend(otherScene) merged step modules. Plus explicit step
    jumping (named + numeric), state accumulation, last-step persist-only
    behavior, options.firstTime suppression of next step's enter.

  • tests/step-navigation.test.ts (7) — step.next/.previous/.go for
    named ids, mixed numeric+named, throw conditions on first/last step,
    tracking of step.id/step.previousId across transitions.

  • tests/step-lifecycle.test.ts (9) — .enter once-on-firstTime,
    .message text + factory forms, .message + .enter combined ordering,
    .fallback on no-match, step-scoped .command, scene-level .command
    escape hatch from any builder step, scene-level + step-local
    .derive() flow into ctx.

  • tests/scene-as-composer.test.ts (8) — full Composer DSL on Scene:
    .derive, .decorate, .callbackQuery, .hears, .guard (gate-mode and
    conditional-middleware-mode), .extend(plugin), and registration-
    order precedence between scene-level and step-level handlers.

Bug fixes uncovered

  1. dispatchActive fallback logic was inverted — `terminal` (passthrough)
     fired before we knew whether the step chain fell through, and the
     fallback condition compared the wrong flag. Rewrote the routing as
     a clean if/else inside the wrapper:
       step chain handled it     → done
       fell through + fallback   → run fallback (consume update)
       fell through + no fallback → call next() (passthrough)

  2. firstTime path was running only derive/decorate, skipping .guard
     middleware. A guard meant to gate scene access was bypassed on
     entry. Added "guard" to the setup-types whitelist; if a guard
     stops the chain (calls fail middleware or just returns without
     next() in gate mode), proceed=false and we skip message/enter.

123/123 tests pass.
7 new tests in tests/complex-flows.test.ts covering scenarios users will
actually hit:

  • multi-step onboarding: decorate (analytics) + named navigation + step
    module (.extend) + onEnter/onExit + state accumulation
  • multiple step modules merged into one scene (intro + survey + farewell)
  • the same module reused across multiple scenes with independent params
  • sub-scenes with builder steps + returnData merge into parent state
  • scene.extend(plugin) AND scene.extend(otherScene) chained together
  • step.go(name) preserves previousId across back-and-forth navigation
  • ctx.scene.exit() fires onExit and tears down storage so subsequent
    messages don't trigger any scene handler

Also clarifies in scene.onEnter() JSDoc that scene-level .derive()
results are not visible to the onEnter handler — they're applied during
the dispatch chain which fires AFTER onEnter. Tests in this file use
.decorate() (static deps, available before middleware runs) instead of
.derive() in onEnter, matching the documented contract.

130/130 tests green across 12 files.
Previously, scene.onEnter fired BEFORE the scene's middleware chain ran,
so scene-level .derive() / .decorate() results weren't on ctx yet.
Users had to choose between deriving data and reacting to it on entry.

Now: onEnter fires AFTER derive/decorate apply on the entry update, so
ergonomic patterns like the following just work:

  new Scene("checkout")
      .derive(async ctx => ({ user: await db.users.find(ctx.from!.id) }))
      .onEnter(ctx => analytics.track("checkout_start", { user: ctx.user }))
      .step("review", c => c.message("Order looks good?").on("message", ...))

Implementation:
  • New `entered: boolean` field on ScenesStorageData distinguishes
    "scene first entry" (fire onEnter) from "step.go() with firstTime=true"
    (don't re-fire). Set false in getSceneEnter / getSceneEnterSub, flipped
    true after dispatchActive runs onEnter.
  • Builder mode: dispatchActive's firstTime path runs the existing setup
    chain (derive/decorate/guard), then fires onEnter if !data.entered,
    then proceeds to message/enter.
  • Legacy mode: a small wrapper in dispatchActive runs derive/decorate
    ahead of the inherited dispatch chain, fires onEnter, then resumes —
    so onEnter still sees derives. The dispatch chain itself runs derives
    once again for handler use, but stateful side effects live on the
    derive's first invocation. (For scenes without onEnter, no extra run
    happens.)
  • Storage migration: existing data without `entered` field treats it as
    falsy, which is the correct default — old data has firstTime=false,
    so the gate still skips re-fire.

JSDoc on .onEnter updated; complex-flows test now uses .derive() in
onEnter (the natural pattern). 2 new tests in scene-as-composer.test.ts
explicitly verify (a) derive value is visible in onEnter and (b) onEnter
fires exactly once across step.go() transitions.

132/132 tests green.
README rewritten around the new builder API as primary:
  • Quick-start uses .step("name", c => c.enter().on()) pattern
  • New "Core concepts": Scene IS EventComposer, step builder, named ids
  • New "Reusable step modules — scene.extend(otherScene)" section
    with confirm/contact/checkout example showing module reuse
  • New "Scene lifecycle — onEnter / onExit" section, noting derive
    visibility in onEnter
  • New "ctx.scene.update — auto-advance" with named-step rules
  • New "Type-safe state, params, and exit data" section covering
    .params/.state/.exitData chain + builder-step .updates<T>()
  • Sub-scenes example modernized to builder API
  • Storage shape includes the new `entered?: boolean` field
  • "Legacy step API" appendix documents back-compat disambiguation
    (string in events list → legacy filter; otherwise → named builder)

CLAUDE.md rewritten to mirror the actual architecture:
  • Module map table for src/
  • "Two parallel slots: ~ and ~scene" — explains why composer's slot
    is left untouched and Scene-specific data lives separately
  • Three dispatch paths in dispatchActive documented step-by-step
  • Step API disambiguation rules listed
  • New `entered` flag explained for "scene-first vs step-transition"
  • dispatch/dispatchActive naming rationale (collision with inherited
    Composer.compose/run)
  • "Common patterns when editing" section for future agents

132/132 tests still green.
Type-checks `src/` + all `tests/` (including the new `tests/types/`
`.test-d.ts` files) under strict TS. Used to catch typing regressions
that `bun test` can't — generic chains, override surface, derive
visibility, ctx.scene typing, etc.
The scene-as-composer redesign shipped with several typing gaps that
made `ctx.scene.update(...)` fail to type-check in most places. This
commit closes all of them.

## ctx.scene is now visible in every handler

* Scene gets a `declare "~"` that widens the inherited composer's TOut
  phantom (`~.Out`) to include `Derives["global"]` — which carries the
  scene field. With this, `EventContextOf<this, E>` (used by every
  inherited method's ctx typing) picks up `ctx.scene` automatically.
  Scene-level `.on / .command / .callbackQuery / .hears / .use /
  .onEnter / .onExit` all type ctx with `ctx.scene` now.
* `StepComposerFor<Derives, AccState>` does the same threading at the
  step level — step builder `c.enter / c.exit / c.fallback / c.message`
  and event handlers (`c.on / c.command / ...`) all see `ctx.scene`
  with the parent scene's params/state typed.

## .derive() preserves Scene<...>

Override `.derive` and `.decorate` to delegate to super and re-type as
`Scene<Params, Errors, State, Modify<Derives, { global: D }>>`. Without
this, any chained `.onEnter / .step / .ask` after `.derive(...)` was
stripped from the type by the base class's wider return.

## SceneEnterHandler enforces params

Rewritten as a two-overload interface that reads `Params` from the
SCENE GENERIC (not the runtime `~scene.params` carrier, which was typed
`unknown` and accepted any second-arg shape). Now:
  - `enter(plainScene)`            ✓
  - `enter(typedScene, params)`    ✓
  - `enter(typedScene)`            ✗ params required
  - `enter(typedScene, {wrong:1})` ✗ shape mismatch

Required making `SceneInternals` generic over `<Params, State, ExitData>`
so Scene's structural shape distinguishes `Scene<{id}>` from `Scene<never>`
at call sites (needed by overload resolution).

## State auto-inferred from ctx.scene.update({...}) calls

The legacy `step(event, handler)` already extracted `UpdateData<T>` from
the handler's awaited return and widened Scene's `State`. The builder
form just didn't pipe it through. Now does:

  new Scene("x")
    .step("ask", c => c.on("message", ctx =>
      ctx.scene.update({ name: ctx.text! })))
    .step("read", c => c.enter(ctx => {
      // ctx.scene.state.name: string  ← inferred, no .state<T>() needed
    }));

`StepComposerStateTracked<...>` is a re-typed view of the step composer
whose `.on/.command/.callbackQuery/.hears/.enter/.exit/.fallback`
methods thread `ExtractUpdateState<ReturnType<H>>` into a phantom
`AccState` generic. `Scene.step(name, builder)` reads `AccState` off
the builder's return via `ExtractStepState` and intersects it into
`State`. Handlers that don't call `update()` contribute `{}` so they
don't pollute state.

## Other fixes

* `state<T>()` chain bug: was using `Derives & { global: ... }`
  (intersection) which made `state: Record<string, never> & {count}`
  collapse to `{count: never}`. Switched to `Modify<>` for a clean
  replace.
* `c.updates<T>()` generic order swapped so `c.updates<{name:string}>()`
  works with one type arg (was requiring two).
* Legacy `step("message", handler)` overload moved BEFORE the
  named-builder overload — `T extends UpdateName` cleanly distinguishes
  event names from arbitrary step names, so ctx gets typed properly in
  both forms.

## What's NOT fixed (limitations)

* Module scenes (`new Scene()` with no name) can't know what params the
  host scene declares — write `.params<T>()` on the module too OR cast.
* `bot.extend(scenes([...]))`-side `ctx.scene.enter(scene)` is still
  loosely typed (typed as `any` shape via plugin derive). Tracked
  separately.

Tests: 132/132 runtime + 0 type errors across `src/` + all `tests/`.
Four `.test-d.ts` files covering the API surface end-to-end:

* scene-chain.test-d.ts — Scene generic chain, .params/.state/.exitData
  through chained calls, .ask state inference, SceneEnterHandler arity
  & shape enforcement, InActiveSceneHandlerReturn shape, legacy
  step(event, handler) ctx typing.
* step-builder.test-d.ts — step ctx (.send, ctx.scene), per-event
  narrowing (.on/.command/.callbackQuery/.hears), auto-state inference
  from ctx.scene.update() calls, c.events(), c.updates<T>() escape hatch.
* extend.test-d.ts — scene.extend(scene) state/params/errors merging,
  extend(plugin), stacked merges.
* bot-integration.test-d.ts — bot.extend(scenes([...])) call-site
  typing, scenesDerives.

Erased at runtime; checked via `bun run test:types` (tsc --noEmit -p
tsconfig.test.json). Uses bun:test's `expectTypeOf` (vendored
expect-type) — no extra deps.

These tests are the contract: when a typing regression sneaks in, one
of them fails at tsc time and we know which DX guarantee broke.
* core.test.ts: issue-#6 enter() test no longer uses
  `.parameters.toEqualTypeOf<[scene, params]>` (SceneEnterHandler is now
  an overloaded interface; that form can't enumerate overloads).
  Switched to `toBeCallableWith` which exercises the same contract at
  the actual call site.
* core.test.ts: `capturedPreviousId` widened to `string | number` to
  match the storage type widening from the redesign.
* complex-flows.test.ts: module-scene "tap" now declares
  `.params<{who:string}>()` because the module needs to know what
  params shape its handlers will see (host scenes can't push their
  params shape backward into the module).
* index.test.ts: import path includes `.js` extension (required under
  NodeNext moduleResolution).
Old README was a feature list. New one leads with what users need to
make decisions:

1. Mental model in 30 seconds (Scene + Steps + lifecycle + ctx.scene)
2. 5-minute example with explicit "look ma, no boilerplate" callout
3. Update-flow ASCII diagram (dispatch path from Telegram update →
   plugin → scene → step → handler → persist)
4. "What goes where" decision table — concern → method → reason
5. Sub-scene flow diagram showing the enterSub ⇄ exitSub bounce
6. Reference sections (step builder, ctx.scene, plugin options,
   storage shape, legacy disambiguation) preserved but moved BELOW
   the architectural overview so first-time readers aren't drowning
   in syntax.

Also surfaces sub-scene quirks that previously weren't documented:
the same-update re-dispatch into the parent's resumed step, and the
need to manually mirror child.exitData<T> on parent.state<T> for
typing (no automatic threading yet).
Avoids the unreleased re-export from gramio core. The two helpers
scenes actually needs at type level (DeriveHandler, EventContextOf)
are both exported from the already-published @gramio/composer@0.4.1,
so scenes can ship as a patch without waiting on gramio or composer
to cut a new release.

Also drops a dead ComposerLike import in step-composer.ts.
* package.json: 0.6.0 → 0.7.0-beta.0 (scene-as-composer redesign,
  ctx.scene typing everywhere, auto-state inference, SceneEnterHandler
  fix — all collected from the recent commits on scene-as-composer).
* publish.yml: new `npm_tag` input (default "latest") so we can ship
  to the `beta` dist-tag without touching `latest`. Mirrored as
  `github_release_prerelease` for the GH release.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant