Skip to content

Circular import between ast/query.ts and ast/pivot.ts breaks under some bundlers #1164

Description

@kolmakova

Summary

ast/query.ts and ast/pivot.ts import each other at module scope. Node's ESM loader happens to evaluate them in an order where this works, but the cycle is order-dependent: a bundler that evaluates query.js first throws

ReferenceError: Cannot access 'Query' before initialization

at module-evaluation time, which breaks the whole package for that consumer.

We hit this with Next.js 16 / Turbopack, in both the server and browser bundles. Webpack picks the safe order; Turbopack does not (vercel/next.js#82723 is the issue).

The cycle has been present since 0.27.0, the release that introduced ast/pivot.ts.

The cycle

src/ast/query.ts:2   import { PivotQuery, type PivotSource } from './pivot.js';
src/ast/pivot.ts:8   import { Query, isQuery } from './query.js';
src/ast/pivot.ts:21  export class PivotQuery extends Query {
  • pivot.ts evaluated first — it pulls in query.js, which runs to completion and initializes Query. Control returns, extends Query reads an initialized binding. Works.
  • query.ts evaluated first — it pulls in pivot.js, which evaluates class PivotQuery extends Query while Query is still uninitialized. ReferenceError.

The two directions aren't symmetric, which is what makes this cheap to fix. pivot.ts needs Query at module scope, as a superclass. But query.ts only needs PivotQuery inside a method body:

// src/ast/query.ts:88
static pivot(source: PivotSource) {
  return new PivotQuery(source);
}

That's the only runtime reference to PivotQuery in query.tsPivotSource is already type-only and erases at compile time. So the query.ts → pivot.ts edge exists purely to support a lazily-invoked factory, yet it's what allows the fatal ordering.

Reproduction

await import('@uwdata/mosaic-sql')

in any Next.js 16 app built with next build --turbopack.

Suggested fix

Bind PivotQuery lazily so query.ts no longer pulls pivot.ts at module scope. Public API is unchanged:

// src/ast/query.ts
import { type PivotSource } from './pivot.js';  // type-only: erased, no runtime edge

let PivotQuery: typeof import('./pivot.js').PivotQuery;

/** @internal Called by ./pivot.ts once both classes are defined. */
export function registerPivotQuery(ctor: typeof PivotQuery): void {
  PivotQuery = ctor;
}
// src/ast/pivot.ts
import { Query, isQuery, registerPivotQuery } from './query.js';

export class PivotQuery extends Query {
  /* unchanged */
}

registerPivotQuery(PivotQuery);

Alternatively, move the Query.pivot() factory into its own module that imports both — that removes the cycle outright rather than making it order-independent, at the cost of relocating one static method.

We're running the first version as a patch against 0.30.0; both our server and browser bundles build and run correctly. Happy to open a PR if you have a preference between the two.

Environment

  • @uwdata/mosaic-sql 0.30.0 (verified; the import structure looks long-standing, so newer releases are likely affected — worth confirming against main)
  • Next.js 16.3.0 with Turbopack; a webpack build of the same app is unaffected
  • Node 24, pnpm 11

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions