Skip to content

fix: resolution bugs, typed tokens, faster resolve - #126

Merged
ivandotv merged 3 commits into
mainfrom
improvements
Aug 15, 2026
Merged

fix: resolution bugs, typed tokens, faster resolve#126
ivandotv merged 3 commits into
mainfrom
improvements

Conversation

@ivandotv

Copy link
Copy Markdown
Owner

Fixes a set of resolution bugs, adds typed tokens and a few container APIs, and cuts per-resolve allocations. Ships with three changesets (one major, one minor, one patch).

Fixes

  • A SINGLETON/REQUEST binding whose class or factory produced undefined returned an internal sentinel symbol on the first resolve, then injected that symbol into every dependent afterwards.
  • validate / validateSafe silently gave up: reaching a bind key that an earlier binding already listed as a dependency aborted the whole check, so validate did not throw and validateSafe returned undefined. Missing dependencies are now reported regardless of bind order, and optional ones are not reported at all.
  • Resolving a parent-owned SINGLETON started a fresh request context. That split REQUEST scope into two instances within a single resolve call, ran postConstruct before the outer graph finished building, and hid circular references behind a stack overflow.
  • setParent accepted a parent that formed a cycle, turning every lookup into infinite recursion. It now throws, and accepts undefined to detach.
  • unbindAll ignored the lock when the container was empty.
  • using container = new PumpIt() threw Container is locked at scope exit on a locked container. Throwing out of a disposal masks the original error behind a SuppressedError, so disposal now bypasses the lock. unbindAll still refuses a locked container.
  • Circular reference errors report the full resolution path with bound class names instead of stringifying an internal wrapper function.

Features

  • token<T>() — typed bind keys. resolve infers the result instead of being told, and bindings under the token are type checked. resolve also infers the instance type when a class is the key.
  • tryResolve — returns undefined for an unbound key instead of throwing. A missing required dependency of a bound key still throws.
  • getKeys — lists keys bound on the container, optionally walking the parent chain.
  • replace bind option — rebind a key without unbinding first; the previous binding is unbound, disposing its cached singleton.
  • Symbol.dispose support on resolved singletons (preferred over dispose), and on the container itself.
  • Every thrown error is a PumpitError carrying a machine-readable code, exported as ERROR_CODE.

Performance

Bound values ~2.5x, cached singletons ~5x, a small transient graph ~2.4x — from allocating request-context maps lazily, parsing injection metadata once at bind time, and dropping the write-only transient cache plus the per-lookup wrapper object.

Breaking changes

  • PumpitError is now the base class for every container error and its constructor takes an ErrorCode instead of ValidationError[]. Validation failures throw PumpitValidationError, which still carries result but whose message lists the unresolved keys instead of the literal string "Validation".
  • validateSafe always returns a ValidationResult, never undefined.
  • Injection metadata is read once at bind time, so registerInjections (or assigning inject / INJECT_KEY) must happen before bindClass / bindFactory. Later changes are no longer picked up.
  • Optional dependencies are no longer reported by validate / validateSafe.
  • resolve infers its return type for class and typed-token keys where it previously widened to unknown.

Tests

Adds container-api.test.ts, regressions.test.ts, and token.test.ts. The regression suite also pins behaviour nothing previously guarded — parent-owned singletons not reaching child-only deps, sibling sharing, throwing constructors/postConstruct leaving the container usable and retryable, replace scoping, and typed tokens as optional deps and through a child.

🤖 Generated with Claude Code

ivandotv and others added 3 commits August 14, 2026 14:13
Correctness:
- a SINGLETON/REQUEST binding producing `undefined` returned an internal
  symbol on the first resolve and injected it into dependents afterwards
- validate/validateSafe silently aborted when a bind key had already been
  seen as another binding's dependency
- resolving a parent owned SINGLETON started a fresh request context, which
  split REQUEST scope, reordered postConstruct and hid circular references
- setParent accepted cycles, turning every lookup into infinite recursion
- unbindAll ignored the lock on an empty container

Features:
- token<T>() typed bind keys, so resolve infers instead of being told
- tryResolve, getKeys, and a `replace` bind option
- Symbol.dispose on resolved singletons and on the container itself
- every error is a PumpitError carrying an ERROR_CODE

Performance (bound value ~2.5x, cached singleton ~5x, transient graph ~2.4x):
- allocate request context maps lazily instead of on every resolve
- parse injection metadata once at bind time
- drop the write-only transient cache and the per lookup wrapper object

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`using container = new PumpIt()` threw "Container is locked" at scope exit
when the container had been locked. Throwing out of a disposal is harmful in
JS: an error raised by the block body gets wrapped in a SuppressedError and
the original is masked. The lock guards callers editing bindings, while
`using` owns the whole lifetime, so disposal now removes bindings directly.
`unbindAll` still refuses a locked container.

Adds tests for behaviours nothing guarded, all of which already passed:
- a parent owned singleton cannot reach a child only dependency, and
  siblings share one instance
- a throwing constructor, a missing dependency and a caught circular
  reference all leave the container usable and retryable
- a throwing postConstruct propagates, skips remaining hooks, and leaves the
  singleton cached
- Symbol.dispose on a factory result
- replace is scoped to the container it is called on
- typed tokens as optional dependencies, and resolved from a child

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI pinned pnpm 9, but `pnpm-workspace.yaml` (added in 9d792d0) only sets
`allowBuilds` and has no `packages` field. pnpm 9 requires that field and
bailed with "packages field missing or empty", so `pnpm store path` wrote
nothing to STORE_PATH and actions/cache failed with "Input required and not
supplied: path". `pnpm install` would have failed for the same reason.
`allowBuilds` is a pnpm 11 setting, so CI now runs 11 to match local.

The store path step also masked the error: `echo "X=$(cmd)"` exits 0 even
when the substitution fails, so the step went green while exporting an empty
value. Assigning first lets `set -e` fail it at the real cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ivandotv
ivandotv merged commit 91cbfc4 into main Aug 15, 2026
1 check passed
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