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.ts — PivotSource 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
Summary
ast/query.tsandast/pivot.tsimport 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 evaluatesquery.jsfirst throwsat 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
pivot.tsevaluated first — it pulls inquery.js, which runs to completion and initializesQuery. Control returns,extends Queryreads an initialized binding. Works.query.tsevaluated first — it pulls inpivot.js, which evaluatesclass PivotQuery extends QuerywhileQueryis still uninitialized. ReferenceError.The two directions aren't symmetric, which is what makes this cheap to fix.
pivot.tsneedsQueryat module scope, as a superclass. Butquery.tsonly needsPivotQueryinside a method body:That's the only runtime reference to
PivotQueryinquery.ts—PivotSourceis already type-only and erases at compile time. So thequery.ts → pivot.tsedge exists purely to support a lazily-invoked factory, yet it's what allows the fatal ordering.Reproduction
in any Next.js 16 app built with
next build --turbopack.Suggested fix
Bind
PivotQuerylazily soquery.tsno longer pullspivot.tsat module scope. Public API is unchanged: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-sql0.30.0 (verified; the import structure looks long-standing, so newer releases are likely affected — worth confirming againstmain)