Skip to content

migrate to typescript - #17

Draft
sderrow wants to merge 12 commits into
use-ts-for-scriptsfrom
migrate-to-ts
Draft

migrate to typescript#17
sderrow wants to merge 12 commits into
use-ts-for-scriptsfrom
migrate-to-ts

Conversation

@sderrow

@sderrow sderrow commented Aug 29, 2026

Copy link
Copy Markdown
Owner

No description provided.

@sderrow sderrow changed the title beginnings migrate to typescript Aug 29, 2026

sderrow commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

Pure rename, no content changes. Code changes follow in the next commit.
@sderrow
sderrow changed the base branch from main to graphite-base/17 August 30, 2026 21:36
@sderrow
sderrow changed the base branch from graphite-base/17 to use-ts-for-scripts August 30, 2026 21:36
- Convert all src modules from CJS to ESM with strict types
  (shared shapes in src/types.ts, structural peer-client types in
  src/cluster/redis-types.ts)
- ESM-cycle fix: Group <-> Bottleneck now use a lazy static
  accessor for Bottleneck.Group (a static field initializer hits
  the TDZ when Group.ts is imported first under ESM)
- package.json: type: module; dist emitted as CJS via .cjs
  (dist/index.cjs, dist/light.cjs) so require() consumers keep
  working; tsdown entries point at src/index.ts
- Tests: convert require() of src/bridge/helpers to ESM imports,
  bridge becomes test/bottleneck.mjs, keep-alive fixtures run
  through tsx so src loads with extensionless specifiers
- tsconfig includes src (strict) and scripts

Known issue: cluster-coordination 'pass messages after Group
re-instantiations' (node-redis project) intermittently times out
due to a node-redis v6 subscribe/unsubscribe race; under
investigation separately.

Modernize the light build: UMD -> ESM

- dist/light.js is now a pure ESM build for <script type="module">
  and bundlers; the CJS-flavored UMD wrapper and its .cjs naming
  (awkward under type: module) are gone
- exports["./light"] points at dist/light.js; README updated

Build the main library in dual ESM/CJS formats

- tsdown lib config: format: [esm, cjs] -> dist/index.mjs +
  dist/index.cjs; exports map gains import/require conditions
  (types still pinned to bottleneck.d.ts until generated dts
  lands)
- README build docs updated

Build the main library in dual ESM/CJS formats

- tsdown lib config: format: [esm, cjs] -> dist/index.mjs +
  dist/index.cjs; exports map gains import/require conditions
  (types still pinned to bottleneck.d.ts until generated dts
  lands)
- README build docs updated

browser for light
- dts: true on both tsdown builds: dist/index.d.mts/.d.cts and
  dist/light.d.ts are now generated from the typed source
- exports map: per-condition types (import -> index.d.mts,
  require -> index.d.cts); ./light -> light.d.ts
- Delete the hand-written bottleneck.d.ts (626 lines) and the
  consumer test.ts that exercised it; the published types are now
  generated from the same source the runtime tests cover
- Tighten public types to (and beyond) the old contract:
  ConstructorOptions/JobOptions/StopOptions/BatcherOptions with
  docs, Strategy as literal union (1|2|3|4), typed event maps
  (BottleneckEvents/GroupEvents/BatcherEvents), schedule()/wrap()
  overloads that unwrap async task results (Awaited<R>) and accept
  options+args, honest return types (running/done -> Promise<number>,
  currentReservoir/incrementReservoir -> Promise<number | null>)
- Public types re-exported from the package root; types.test.ts
  asserts the contract with expectTypeOf
- Pure rename of all test/**/*.js -> .ts (plus bottleneck.mjs ->
  bottleneck.ts). Pre-commit lint is skipped for this commit:
  oxlint's vitest rules apply to .ts files, so the hook cannot pass
  on a content-free rename; lint/type fixes follow in the next
  commit.
- Also removes the hand-written bottleneck.d.ts and its consumer
  test.ts from the repo root (superseded by generated dts and
  test/types.test.ts) — follow-up missed by the dts commit's
  pathspec.
- Foundation: test/bottleneck.ts bridge gets an explicit class type
  (typeof BottleneckBase); helpers typed (limiter, job-tasks,
  wait-for-state, redis-client-options, leakage); test-api.ts gains
  a typed fixtures contract (JobHarness, MakeLimiter/MakeGroup/
  MakeConnection, Track) plus vitest Assertion declaration-merging
  for the custom toHaveCallOrder/toHaveFinalCallAt/toHaveCallAt
  matchers
- All test files typed: vi.fn type parameters, callback params,
  non-null assertions under noUncheckedIndexedAccess, casts for
  library internals (_store/connection) and stub objects; no
  runtime test semantics changed
- vitest project include/exclude globs updated .js -> .ts (cluster
  and *redis files are excluded from the local project again,
  batcher from the redis projects)

Known issue unchanged: cluster-coordination 'pass messages after
Group re-instantiations' flake (documented in .context/ts-migration-plan.md)
- Drop allowJs (no JS remains in the checked tree; fixtures stay .mjs)
- Enable noUnusedLocals/noUnusedParameters; rename intentionally
  unused params to _-prefixed
- Update doc references from .js to .ts test/source paths
  (README, AGENTS.md)
- Tag every underscore-prefixed member (fields and methods, incl. the
  __-prefixed datastore API) across src with /** @internal */ and
  enable stripInternal in tsconfig: rolldown-plugin-dts honors it, so
  dist/index.d.mts/.d.cts and dist/light.d.ts no longer leak internals
  (verified: zero underscore members in the generated declarations)
- In-repo type checking is unaffected (@internal only affects
  declaration emit), so the suite's legitimate use of internals
  (_submitLock, _store, _states, ...) keeps compiling
- test/bottleneck.ts: the bridge now casts the dist entrypoints
  through the src class type — dist dts (stripped) and source types
  (unstripped) legitimately differ on internal members, so the
  structural comparison at that seam is meaningless
Found by retroactively diffing the old hand-written bottleneck.d.ts
(16f94eb^) against dist/index.d.mts: Events installs on/once at
runtime on the connection instances and the old contract declared
them, but the generated dts omitted them. Declared as typed members
('error' event + string fallback).
- Tag 47 runtime-public members that were never part of the published
  contract or README (Bottleneck: *Defaults objects, version, Promise,
  rejectOnDrop/trackDoneStatus, channel_client; Group: instances,
  interval, limiterOptions, sharedConnection, ...; Batcher: maxTime/
  maxSize/options/Events; connections: Redis/client/subscriber/limiters/
  shas/terminated/...) with @internal so stripInternal keeps them out
  of the generated dts
- Restore the discriminated connection-option unions from the old
  contract (Redis xor client, per connection type) as
  RedisConnectionOptions/IORedisConnectionOptions in src/types.ts,
  re-exported from the package root; the ctors now accept them and
  RedisDatastore no longer passes the unused Promise key
- types.test.ts asserts exclusivity via @ts-expect-error (Redis+client
  must not compile)

Found by diffing the old hand-written contract against the generated
dts; verified zero unintended deltas remain.
Replace the let ExportedBottleneck! + if/else with a makeTestBottleneck()
function that returns the wrapped class, so both paths are symmetric and
the definite-assignment assertion is gone. No behavior change.
The typed conversion dropped the await before
subscriber.unsubscribe(channel) in both RedisConnection and
IORedisConnection, so Group.deleteKey()/disconnect() returned before
the UNSUBSCRIBE was processed server-side. A limiter re-created on the
same channel (Group re-instantiation) then raced the in-flight
UNSUBSCRIBE: node-redis v6 silently skips issuing SUBSCRIBE when a
listener entry for the channel still exists, so the channel ended up
unsubscribed and the new limiter never received messages — the
cluster-coordination 'pass messages after Group re-instantiations'
flake (was ~50% on this branch, 0/10 failures after this fix; main was
green only because its CJS code had the await).

Diagnosed with temporary instrumentation inside @redis/client (since
removed): PUBSUB-UNSUB -> subscribe-skip -> PUBSUB-DELETE-ENTRY
ordering captured the race directly.
Second dropped-await from the conversion, same family as cbb1efd:
subscriber.subscribe(channel, cb) was not awaited, so __addLimiter__
resolved before the subscription was confirmed and the subscribe
promises were left unhandled — rejected by destroy()'s flushAll during
teardown with 'Disconnects client', surfacing as unhandled rejections
that failed the vitest run exit code (14 in a full suite).

Also:
- dedupe doubled /** @internal */ tags on __-methods
- types.test.ts: connection fakes satisfy _setup/__addLimiter__ sync
  and async surface (setMaxListeners/on/once/duplicate) so the test no
  longer leaks unhandled rejections

Full suite: 56 files / 568 passed / 0 unhandled errors.
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