Repository: ponder-sh/ponder
Pull request #1486 (2025-02-05 05:00:38)

wip: build core with tsc
- Migrates `core` to use `tsc` via a custom build script, similar to what we did for `@ponder/react`
- Removes private `common` package, copy/pastes required utility function into `core/src/utils` and `utils/src/utils`
- Use a custom tsconfig-paths replacer script
- Improves sourcemaps, go-to-definition now goes to the actual ts source


Patch:
diff --git a/.changeset/config.json b/.changeset/config.json
index e7bf9520..971c84df 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -12,7 +12,6 @@
     ]
   ],
   "ignore": [
-    "@ponder/common",
     "ponder-examples-feature-accounts",
     "ponder-examples-feature-blocks",
     "ponder-examples-feature-factory",
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index b4c6083e..2315bee2 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -80,42 +80,3 @@ jobs:
         run: pnpm --filter create-ponder test
         env:
           ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
-
-  test-common:
-    name: Test [common]
-    runs-on: ubuntu-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        runtime-version: ["Node", "Bun"]
-    steps:
-      - name: Clone repository
-        uses: actions/checkout@v4
-
-      - name: Set up pnpm
-        uses: pnpm/action-setup@v3
-        with:
-          version: 9.10.0
-
-      - name: Install Node
-        if: ${{ matrix.runtime-version == 'Node' }}
-        uses: actions/setup-node@v3
-        with:
-          cache: pnpm
-          node-version: 20
-
-      - name: Install Bun
-        if: ${{ matrix.runtime-version == 'Bun' }}
-        uses: oven-sh/setup-bun@v1
-
-      - name: Install dependencies
-        shell: bash
-        run: pnpm install --frozen-lockfile
-
-      - name: Test Node
-        if: ${{ matrix.runtime-version == 'Node' }}
-        run: cd packages/common && pnpm test:typecheck
-
-      - name: Test Bun
-        if: ${{ matrix.runtime-version == 'Bun' }}
-        run: cd packages/common && bun test
diff --git a/packages/common/.gitignore b/packages/common/.gitignore
deleted file mode 100644
index 4c245931..00000000
--- a/packages/common/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-node_modules
-dist
-.env.local
\ No newline at end of file
diff --git a/packages/common/README.md b/packages/common/README.md
deleted file mode 100644
index d004f7dd..00000000
--- a/packages/common/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# @ponder/common
-
-An internal package containing code that is common to multiple packages in the monorepo.
\ No newline at end of file
diff --git a/packages/common/package.json b/packages/common/package.json
deleted file mode 100644
index 6369e89e..00000000
--- a/packages/common/package.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
-  "name": "@ponder/common",
-  "private": "true",
-  "version": "0.0.0",
-  "license": "MIT",
-  "type": "module",
-  "sideEffects": false,
-  "main": "./dist/index.cjs",
-  "module": "./dist/index.js",
-  "types": "./dist/index.d.ts",
-  "typings": "./dist/index.d.ts",
-  "files": [
-    "dist",
-    "!dist/**/*.tsbuildinfo",
-    "src/**/*.ts",
-    "!src/**/*.test.ts",
-    "!src/**/*.bench.ts",
-    "!src/**/_test/**/*"
-  ],
-  "repository": {
-    "type": "git",
-    "url": "https://github.com/ponder-sh/ponder",
-    "directory": "packages/common"
-  },
-  "exports": {
-    ".": {
-      "types": "./dist/index.d.ts",
-      "import": "./dist/index.js",
-      "default": "./dist/index.cjs"
-    }
-  },
-  "scripts": {
-    "build": "tsup",
-    "test": "vitest",
-    "test:typecheck": "vitest --typecheck",
-    "typecheck": "tsc --noEmit"
-  },
-  "devDependencies": {
-    "@types/node": "^20.11.24",
-    "dotenv": "^16.3.1",
-    "tsup": "^8.0.1",
-    "vitest": "^1.0.2"
-  }
-}
diff --git a/packages/common/src/debounce.ts b/packages/common/src/debounce.ts
deleted file mode 100644
index 53b5b692..00000000
--- a/packages/common/src/debounce.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Creates a debounced function that waits ms milliseconds between invocations.
- * If the function is called multiple times between invocations, the latest
- * arguments passed to the function will be used.
- */
-export function debounce<param extends unknown[], returnType>(
-  ms: number,
-  fn: (...x: param) => returnType,
-) {
-  let args: param;
-  let timeoutSet = false;
-  let timeout: NodeJS.Timeout;
-
-  return {
-    call: (..._args: param) => {
-      args = _args;
-
-      if (!timeoutSet) {
-        timeoutSet = true;
-        timeout = setTimeout(() => {
-          timeoutSet = false;
-          fn(...args);
-        }, ms);
-      }
-    },
-    cancel: () => {
-      clearTimeout(timeout);
-    },
-  };
-}
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
deleted file mode 100644
index 9cbce9c5..00000000
--- a/packages/common/src/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export {
-  promiseWithResolvers,
-  type PromiseWithResolvers,
-} from "./promiseWithResolvers.js";
-export { debounce } from "./debounce.js";
-export { dedupe } from "./dedupe.js";
-export { retry } from "./retry.js";
-export { type Queue, createQueue } from "./queue.js";
-export { poll } from "./poll.js";
diff --git a/packages/common/src/poll.ts b/packages/common/src/poll.ts
deleted file mode 100644
index 9d9f970a..00000000
--- a/packages/common/src/poll.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// Adapted from viem.
-// https://github.com/wagmi-dev/viem/blob/38422ac7617022761ee7aa87310dd89adb34573c/src/utils/poll.ts
-
-type PollOptions = {
-  // Whether or not to invoke the callback when the polling starts.
-  invokeOnStart?: boolean;
-  // The interval (in ms).
-  interval: number;
-};
-
-/**
- * @description Polls a function at a specified interval.
- */
-export const poll = (
-  fn: () => Promise<unknown> | unknown,
-  { invokeOnStart, interval }: PollOptions,
-) => {
-  let cancelled = false;
-
-  if (invokeOnStart) fn();
-  const timeout = setInterval(() => {
-    if (!cancelled) fn();
-  }, interval);
-
-  return () => {
-    cancelled = true;
-    clearInterval(timeout);
-  };
-};
diff --git a/packages/common/src/retry.ts b/packages/common/src/retry.ts
deleted file mode 100644
index ba38658f..00000000
--- a/packages/common/src/retry.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { promiseWithResolvers } from "./promiseWithResolvers.js";
-
-export const retry = <returnType>(
-  callback: () => Promise<returnType>,
-  {
-    retries = 3,
-    timeout = 100,
-    exponential = true,
-    onRetry,
-  }: {
-    retries?: number;
-    timeout?: number;
-    exponential?: boolean;
-    onRetry?: (error: any, duration: number) => void;
-  } = { retries: 3, timeout: 100, exponential: true },
-): { promise: Promise<returnType>; cancel: () => void } => {
-  const { promise, resolve, reject } = promiseWithResolvers<returnType>();
-
-  let error: any;
-  let hasError = false;
-
-  let canceled = false;
-  let timer: NodeJS.Timeout | undefined;
-
-  const process = async () => {
-    for (let i = 0; i < retries + 1; i++) {
-      if (canceled) return;
-      try {
-        const out = await callback();
-        resolve(out);
-        return;
-      } catch (_error) {
-        if (!hasError) {
-          hasError = true;
-          error = _error;
-        }
-
-        if (canceled) return;
-
-        if (i < retries) {
-          const duration = exponential ? timeout * 2 ** i : timeout;
-          onRetry?.(_error, duration);
-          await new Promise((_resolve) => {
-            timer = setTimeout(_resolve, duration);
-          });
-        }
-      }
-    }
-    reject(error);
-  };
-
-  process();
-
-  return {
-    promise,
-    cancel: () => {
-      canceled = true;
-      clearTimeout(timer);
-      reject(new Error("Retry canceled"));
-    },
-  };
-};
diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json
deleted file mode 100644
index 1a06d556..00000000
--- a/packages/common/tsconfig.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
-  // Adapted from viem (https://github.com/wagmi-dev/viem/blob/ed779e9d5667704fd7cfc5a2af032f7a2c3cae9e/tsconfig.base.json).
-  "include": ["src"],
-  "compilerOptions": {
-    // Type checking
-    "strict": true,
-    "useDefineForClassFields": true,
-    "noFallthroughCasesInSwitch": true,
-    "noImplicitReturns": true,
-    "useUnknownInCatchVariables": true,
-    "noImplicitOverride": true,
-    "noUnusedLocals": true,
-    "noUnusedParameters": true,
-    "noUncheckedIndexedAccess": true,
-
-    // JavaScript support
-    "allowJs": false,
-    "checkJs": false,
-    "jsx": "react",
-
-    // Interop constraints
-    "verbatimModuleSyntax": true,
-    "esModuleInterop": true,
-    "allowSyntheticDefaultImports": true,
-    "resolveJsonModule": true,
-
-    // Language and environment
-    "moduleResolution": "NodeNext",
-    "module": "NodeNext",
-    "target": "ESNext",
-    "lib": [
-      "ES2022" // By using ES2022 we get access to the `.cause` property on `Error` instances.
-    ],
-
-    // Skip type checking for node modules
-    "skipLibCheck": true
-  }
-}
diff --git a/packages/common/tsup.config.ts b/packages/common/tsup.config.ts
deleted file mode 100644
index 54edd856..00000000
--- a/packages/common/tsup.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from "tsup";
-
-export default defineConfig({
-  name: "@ponder/common",
-  entry: ["src/index.ts"],
-  outDir: "dist",
-  format: ["esm", "cjs"],
-  sourcemap: true,
-  dts: true,
-  clean: true,
-  splitting: true,
-});
diff --git a/packages/core/build.ts b/packages/core/build.ts
new file mode 100644
index 00000000..bfed75e4
--- /dev/null
+++ b/packages/core/build.ts
@@ -0,0 +1,209 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { watch } from "chokidar";
+import { execa } from "execa";
+import { glob } from "glob";
+import pc from "picocolors";
+import { rimraf } from "rimraf";
+
+const PACKAGE_NAME = "@PONDER/CORE";
+
+const TSCONFIG = "tsconfig.build.json";
+const WATCH_DIRECTORY = "src";
+
+const prefix = pc.gray(`[${PACKAGE_NAME}]`);
+const log = {
+  cli: (msg: string) => console.log(`${prefix} ${pc.magenta("CLI")} ${msg}`),
+  error: (msg: string) => console.error(`${prefix} ${pc.red("ERROR")} ${msg}`),
+  success: (msg: string) => console.log(`${prefix} ${pc.green("CLI")} ${msg}`),
+  tsc: (msg: string) => console.log(`${prefix} ${pc.blue("TSC")} ${msg}`),
+};
+
+const isWatchMode =
+  process.argv.includes("--watch") || process.argv.includes("-w");
+
+if (isWatchMode) {
+  watchMode().catch((error) => {
+    log.error(`Watch mode failed: ${error}`);
+    process.exit(1);
+  });
+} else {
+  build().then((success) => {
+    process.exit(success ? 0 : 1);
+  });
+}
+
+async function build() {
+  try {
+    log.cli("Build start");
+    const startTime = Date.now();
+
+    await rimraf("dist");
+    log.cli("Cleaned output folder");
+
+    const tscResult = await execa("tsc", ["--project", TSCONFIG], {
+      reject: false,
+      stderr: "pipe",
+      stdout: "pipe",
+    });
+
+    `${tscResult.stdout}\n${tscResult.stderr}`
+      .trim()
+      .split("\n")
+      .filter(Boolean)
+      .forEach((line) => log.tsc(line));
+
+    if (tscResult.exitCode !== 0) {
+      log.error("Build failed");
+      return false;
+    } else {
+      log.cli("Completed tsc without error");
+    }
+
+    const replacePathsResult = replaceAliasedPaths();
+    if (replacePathsResult.error) {
+      log.error(`Failed to replace import paths: ${replacePathsResult.error}`);
+      return false;
+    }
+
+    const duration = Date.now() - startTime;
+    log.success(`⚡️ Build success in ${duration}ms`);
+    return true;
+  } catch (error) {
+    log.error(`Build failed: ${error}`);
+    return false;
+  }
+}
+
+async function watchMode() {
+  await build();
+
+  const watcher = watch(WATCH_DIRECTORY, {
+    cwd: path.dirname(fileURLToPath(import.meta.url)),
+    persistent: true,
+  });
+
+  let isBuilding = false;
+  let isBuildQueued = false;
+
+  async function enqueueBuild() {
+    if (!isBuilding) {
+      try {
+        isBuilding = true;
+        await build();
+      } finally {
+        isBuilding = false;
+      }
+      if (isBuildQueued) {
+        isBuildQueued = false;
+        await enqueueBuild();
+      }
+    } else {
+      isBuildQueued = true;
+    }
+  }
+
+  watcher.on("change", async (path) => {
+    log.cli(`Change detected: ${path}`);
+    await enqueueBuild();
+  });
+
+  watcher.on("error", (error) => {
+    log.error(`Watch error: ${error}`);
+  });
+
+  watcher.on("ready", () => {
+    log.cli(`Watching for changes in "${WATCH_DIRECTORY}"`);
+  });
+
+  process.on("SIGINT", () => {
+    watcher.close().then(() => {
+      log.cli("Watch mode terminated");
+      process.exit(0);
+    });
+  });
+}
+
+const importRegex = /from ['"](@\/[^'"]*)['"]/g;
+
+function replaceAliasedPaths() {
+  try {
+    const directories = ["dist/esm", "dist/types"];
+
+    for (const dir of directories) {
+      let replacementCount = 0;
+
+      // Normalize the directory path for the current OS
+      const normalizedDir = path.normalize(dir);
+
+      const filePaths = glob.sync(`${normalizedDir}/**/*`, { nodir: true });
+
+      for (const filePath of filePaths) {
+        const content = fs.readFileSync(filePath, "utf8");
+
+        // Get the corresponding source path relative to src directory
+        // Use posix-style paths for consistency in the relative path calculation
+        const relativePath = path
+          .relative(normalizedDir, filePath)
+          // Convert Windows backslashes to forward slashes for consistency
+          .split(path.sep)
+          .join("/");
+
+        const sourceFilePath = path.join("src", relativePath);
+        // Ensure we use forward slashes for the source directory path
+        const sourceFileDir = path
+          .dirname(sourceFilePath)
+          .split(path.sep)
+          .join("/");
+
+        // Replace all @/ imports with relative paths
+        const newContent = content.replace(
+          importRegex,
+          (_match, importPath) => {
+            // Remove @ prefix
+            const importWithoutAlias = importPath.replace("@/", "");
+            // Use forward slashes for the target path
+            const targetPath = path
+              .join("src", importWithoutAlias)
+              .split(path.sep)
+              .join("/");
+
+            // Create relative path from the source file to the target
+            // and ensure it uses forward slashes
+            let relativePath = path
+              .relative(sourceFileDir, targetPath)
+              .split(path.sep)
+              .join("/");
+
+            // Ensure the path starts with ./ or ../
+            if (!relativePath.startsWith(".")) {
+              relativePath = `./${relativePath}`;
+            }
+
+            const replacementText = `from '${relativePath}'`;
+
+            // Useful for debugging.
+            // console.log({ file: filePath, old: _match, new: replacementText });
+
+            replacementCount++;
+            return replacementText;
+          },
+        );
+
+        if (newContent !== content) {
+          fs.writeFileSync(filePath, newContent);
+        }
+      }
+
+      log.cli(
+        `Replaced import paths in '${normalizedDir}' (${filePaths.length} files, ${replacementCount} replacements)`,
+      );
+    }
+
+    return { error: null };
+  } catch (e) {
+    const error = e as Error;
+    return { error };
+  }
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index 13403e40..a3340553 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -12,27 +12,29 @@
   "files": [
     "dist",
     "src/**/*.ts",
-    "!src/**/*.test.ts",
-    "!src/**/*.test-d.ts",
-    "!src/_test/**/*"
+    "!src/_test/**/*",
+    "!**/*.test.ts",
+    "!**/*.test-d.ts",
+    "!**/*.tsbuildinfo",
+    "CHANGELOG.md"
   ],
   "bin": {
-    "ponder": "./dist/bin/ponder.js"
+    "ponder": "./dist/esm/bin/ponder.js"
   },
-  "module": "./dist/index.js",
-  "types": "./dist/index.d.ts",
-  "typings": "./dist/index.d.ts",
+  "module": "./dist/esm/index.js",
+  "types": "./dist/types/index.d.ts",
+  "typings": "./dist/types/index.d.ts",
   "exports": {
     ".": {
-      "types": "./dist/index.d.ts",
-      "import": "./dist/index.js"
+      "types": "./dist/types/index.d.ts",
+      "import": "./dist/esm/index.js"
     },
     "./virtual": {
       "types": "./src/types.d.ts"
     }
   },
   "scripts": {
-    "build": "tsup",
+    "build": "tsx build.ts",
     "test": "vitest",
     "test:typecheck": "vitest --typecheck.only",
     "typecheck": "tsc --noEmit"
@@ -84,6 +86,7 @@
   },
   "devDependencies": {
     "@pgsql/types": "16.0.0",
+    "@ponder/client": "workspace:*",
     "@types/babel__code-frame": "^7.0.6",
     "@types/glob": "^8.1.0",
     "@types/node": "^20.10.0",
@@ -91,15 +94,12 @@
     "@types/react": "^18.2.38",
     "@viem/anvil": "^0.0.6",
     "@wagmi/cli": "^1.5.2",
+    "chokidar": "^4.0.3",
     "execa": "^8.0.1",
     "rimraf": "^5.0.5",
-    "tsup": "^8.0.1",
+    "tsx": "^4.19.2",
     "vitest": "^1.0.2"
   },
-  "imports": {
-    "@ponder/common": "../common/index.ts",
-    "@ponder/client": "../client/index.ts"
-  },
   "engines": {
     "node": ">=18.14"
   }
diff --git a/packages/core/src/bin/commands/dev.ts b/packages/core/src/bin/commands/dev.ts
index 919f53c3..3cd8e37e 100644
--- a/packages/core/src/bin/commands/dev.ts
+++ b/packages/core/src/bin/commands/dev.ts
@@ -9,8 +9,8 @@ import { createShutdown } from "@/internal/shutdown.js";
 import { buildPayload, createTelemetry } from "@/internal/telemetry.js";
 import type { IndexingBuild } from "@/internal/types.js";
 import { createUi } from "@/ui/index.js";
+import { createQueue } from "@/utils/queue.js";
 import { type Result, mergeResults } from "@/utils/result.js";
-import { createQueue } from "@ponder/common";
 import type { CliOptions } from "../ponder.js";
 import { createExit } from "../utils/exit.js";
 import { run } from "../utils/run.js";
diff --git a/packages/core/src/bin/ponder.ts b/packages/core/src/bin/ponder.ts
index 76bcb74d..681dc6f0 100644
--- a/packages/core/src/bin/ponder.ts
+++ b/packages/core/src/bin/ponder.ts
@@ -14,7 +14,7 @@ import { start } from "./commands/start.js";
 dotenv.config({ path: ".env.local" });
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
-const packageJsonPath = resolve(__dirname, "../../package.json");
+const packageJsonPath = resolve(__dirname, "../../../package.json");
 const packageJson = JSON.parse(
   readFileSync(packageJsonPath, { encoding: "utf8" }),
 );
diff --git a/packages/core/src/build/configAndIndexingFunctions.ts b/packages/core/src/build/configAndIndexingFunctions.ts
index 3171e5c7..0a83d1f3 100644
--- a/packages/core/src/build/configAndIndexingFunctions.ts
+++ b/packages/core/src/build/configAndIndexingFunctions.ts
@@ -25,8 +25,8 @@ import {
 } from "@/sync/filter.js";
 import type { SyncBlock } from "@/types/sync.js";
 import { chains } from "@/utils/chains.js";
+import { dedupe } from "@/utils/dedupe.js";
 import { toLowerCase } from "@/utils/lowercase.js";
-import { dedupe } from "@ponder/common";
 import { BlockNotFoundError, type Hex, type LogTopic, hexToNumber } from "viem";
 import { buildLogFactory } from "./factory.js";
 
diff --git a/packages/core/src/build/factory.ts b/packages/core/src/build/factory.ts
index 46a79159..7bd66221 100644
--- a/packages/core/src/build/factory.ts
+++ b/packages/core/src/build/factory.ts
@@ -1,7 +1,7 @@
 import type { LogFactory } from "@/internal/types.js";
+import { dedupe } from "@/utils/dedupe.js";
 import { toLowerCase } from "@/utils/lowercase.js";
 import { getBytesConsumedByParam } from "@/utils/offset.js";
-import { dedupe } from "@ponder/common";
 import type { AbiEvent } from "abitype";
 import { type Address, toEventSelector } from "viem";
 
diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts
index 8a242b27..827926f9 100644
--- a/packages/core/src/client/index.ts
+++ b/packages/core/src/client/index.ts
@@ -1,6 +1,6 @@
 import type { Schema } from "@/internal/types.js";
 import type { ReadonlyDrizzle } from "@/types/db.js";
-import { promiseWithResolvers } from "@ponder/common";
+import { promiseWithResolvers } from "@/utils/promiseWithResolvers.js";
 import type { QueryWithTypings } from "drizzle-orm";
 import { type PgSession, pgTable } from "drizzle-orm/pg-core";
 import { createMiddleware } from "hono/factory";
diff --git a/packages/core/src/indexing-store/historical.ts b/packages/core/src/indexing-store/historical.ts
index 0a3e4e66..082952ae 100644
--- a/packages/core/src/indexing-store/historical.ts
+++ b/packages/core/src/indexing-store/historical.ts
@@ -14,7 +14,7 @@ import {
 } from "@/internal/errors.js";
 import type { SchemaBuild } from "@/internal/types.js";
 import { prettyPrint } from "@/utils/print.js";
-import { createQueue } from "@ponder/common";
+import { createQueue } from "@/utils/queue.js";
 import {
   type Column,
   type QueryWithTypings,
diff --git a/packages/core/src/indexing-store/realtime.ts b/packages/core/src/indexing-store/realtime.ts
index 15d66522..4a9f2f84 100644
--- a/packages/core/src/indexing-store/realtime.ts
+++ b/packages/core/src/indexing-store/realtime.ts
@@ -9,6 +9,7 @@ import {
 } from "@/internal/errors.js";
 import type { SchemaBuild } from "@/internal/types.js";
 import { prettyPrint } from "@/utils/print.js";
+import { createQueue } from "@/utils/queue.js";
 import {
   type QueryWithTypings,
   type SQL,
@@ -20,7 +21,6 @@ import {
 } from "drizzle-orm";
 import { type PgTable, getTableConfig } from "drizzle-orm/pg-core";
 import { drizzle } from "drizzle-orm/pg-proxy";
-import { createQueue } from "../../../common/src/queue.js";
 import { normalizeColumn } from "./historical.js";
 import { type IndexingStore, parseSqlError } from "./index.js";
 
diff --git a/packages/core/src/internal/telemetry.ts b/packages/core/src/internal/telemetry.ts
index 3bd7599c..f6622417 100644
--- a/packages/core/src/internal/telemetry.ts
+++ b/packages/core/src/internal/telemetry.ts
@@ -5,8 +5,8 @@ import os from "node:os";
 import path from "node:path";
 import { promisify } from "node:util";
 import type { Options } from "@/internal/options.js";
+import { createQueue } from "@/utils/queue.js";
 import { startClock } from "@/utils/timer.js";
-import { createQueue } from "@ponder/common";
 import Conf from "conf";
 import { type PM, detect, getNpmVersion } from "detect-package-manager";
 import { ShutdownError } from "./errors.js";
diff --git a/packages/core/src/sync-realtime/index.ts b/packages/core/src/sync-realtime/index.ts
index 2f4f65bc..a84c59b6 100644
--- a/packages/core/src/sync-realtime/index.ts
+++ b/packages/core/src/sync-realtime/index.ts
@@ -32,6 +32,7 @@ import type {
   SyncTransactionReceipt,
 } from "@/types/sync.js";
 import { mutex } from "@/utils/mutex.js";
+import type { Queue } from "@/utils/queue.js";
 import { range } from "@/utils/range.js";
 import type { RequestQueue } from "@/utils/requestQueue.js";
 import {
@@ -44,7 +45,6 @@ import {
 } from "@/utils/rpc.js";
 import { startClock } from "@/utils/timer.js";
 import { wait } from "@/utils/wait.js";
-import type { Queue } from "@ponder/common";
 import { type Address, type Hash, hexToNumber, zeroHash } from "viem";
 import { isFilterInBloom, zeroLogsBloom } from "./bloom.js";
 
diff --git a/packages/core/src/sync/fragments.ts b/packages/core/src/sync/fragments.ts
index 0fe3b48c..e71e869c 100644
--- a/packages/core/src/sync/fragments.ts
+++ b/packages/core/src/sync/fragments.ts
@@ -12,7 +12,7 @@ import type {
   TransactionFilter,
   TransferFilter,
 } from "@/internal/types.js";
-import { dedupe } from "@ponder/common";
+import { dedupe } from "@/utils/dedupe.js";
 import type { Address, Hex } from "viem";
 import { isAddressFactory, shouldGetTransactionReceipt } from "./filter.js";
 
diff --git a/packages/core/src/utils/generators.ts b/packages/core/src/utils/generators.ts
index 9b3f95bb..4958296d 100644
--- a/packages/core/src/utils/generators.ts
+++ b/packages/core/src/utils/generators.ts
@@ -1,4 +1,4 @@
-import { promiseWithResolvers } from "@ponder/common";
+import { promiseWithResolvers } from "@/utils/promiseWithResolvers.js";
 
 /**
  * Merges multiple async generators into a single async generator.
diff --git a/packages/core/src/utils/mutex.ts b/packages/core/src/utils/mutex.ts
index 2b4e7cfc..52fdc160 100644
--- a/packages/core/src/utils/mutex.ts
+++ b/packages/core/src/utils/mutex.ts
@@ -1,4 +1,4 @@
-import { type Queue, createQueue } from "@ponder/common";
+import { type Queue, createQueue } from "@/utils/queue.js";
 
 export type Mutex<T, P> = ((params: T) => Promise<P>) & Queue<P, T>;
 
diff --git a/packages/common/src/promiseWithResolvers.test-d.ts b/packages/core/src/utils/promiseWithResolvers.test-d.ts
similarity index 100%
rename from packages/common/src/promiseWithResolvers.test-d.ts
rename to packages/core/src/utils/promiseWithResolvers.test-d.ts
diff --git a/packages/common/src/promiseWithResolvers.test.ts b/packages/core/src/utils/promiseWithResolvers.test.ts
similarity index 100%
rename from packages/common/src/promiseWithResolvers.test.ts
rename to packages/core/src/utils/promiseWithResolvers.test.ts
diff --git a/packages/common/src/promiseWithResolvers.ts b/packages/core/src/utils/promiseWithResolvers.ts
similarity index 100%
rename from packages/common/src/promiseWithResolvers.ts
rename to packages/core/src/utils/promiseWithResolvers.ts
diff --git a/packages/common/src/queue.test-d.ts b/packages/core/src/utils/queue.test-d.ts
similarity index 100%
rename from packages/common/src/queue.test-d.ts
rename to packages/core/src/utils/queue.test-d.ts
diff --git a/packages/common/src/queue.test.ts b/packages/core/src/utils/queue.test.ts
similarity index 100%
rename from packages/common/src/queue.test.ts
rename to packages/core/src/utils/queue.test.ts
diff --git a/packages/common/src/queue.ts b/packages/core/src/utils/queue.ts
similarity index 100%
rename from packages/common/src/queue.ts
rename to packages/core/src/utils/queue.ts
diff --git a/packages/core/src/utils/requestQueue.ts b/packages/core/src/utils/requestQueue.ts
index b8091953..9cbdcd32 100644
--- a/packages/core/src/utils/requestQueue.ts
+++ b/packages/core/src/utils/requestQueue.ts
@@ -1,7 +1,7 @@
 import type { Common } from "@/internal/common.js";
 import { ShutdownError } from "@/internal/errors.js";
 import type { Network } from "@/internal/types.js";
-import { type Queue, createQueue } from "@ponder/common";
+import { type Queue, createQueue } from "@/utils/queue.js";
 import {
   type GetLogsRetryHelperParameters,
   getLogsRetryHelper,
diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json
new file mode 100644
index 00000000..974541a1
--- /dev/null
+++ b/packages/core/tsconfig.build.json
@@ -0,0 +1,19 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "include": ["src/**/*.ts"],
+  "exclude": ["src/**/*.test.ts", "src/**/*.test-d.ts", "src/_test"],
+  "compilerOptions": {
+    "outDir": "./dist/esm",
+    "declarationDir": "./dist/types",
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+
+    "jsx": "react",
+    "rootDir": "src",
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  }
+}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index a5cd4ef0..a6944a85 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -1,47 +1,11 @@
 {
-  // Adapted from viem (https://github.com/wagmi-dev/viem/blob/ed779e9d5667704fd7cfc5a2af032f7a2c3cae9e/tsconfig.base.json).
-  "include": ["src"],
+  "extends": "./tsconfig.build.json",
+  "include": ["src/**/*.ts", "src/**/*.tsx"],
+  "exclude": [],
   "compilerOptions": {
-    // Type checking
-    "strict": true,
-    "useDefineForClassFields": true,
-    "noFallthroughCasesInSwitch": true,
-    "noImplicitReturns": true,
-    "useUnknownInCatchVariables": true,
-    "noImplicitOverride": true,
-    "noUnusedLocals": true,
-    "noUnusedParameters": true,
-    "noUncheckedIndexedAccess": true,
-
-    // JavaScript support
-    "allowJs": false,
-    "checkJs": false,
-    "jsx": "react",
-
-    // Interop constraints
-    "verbatimModuleSyntax": true,
-    "esModuleInterop": true,
-    "allowSyntheticDefaultImports": true,
-    "resolveJsonModule": true,
-
-    // Language and environment
-    "moduleResolution": "NodeNext",
-    "module": "NodeNext",
-    "target": "ESNext",
-    "lib": [
-      "ES2022" // By using ES2022 we get access to the `.cause` property on `Error` instances.
-    ],
-
-    // Skip type checking for node modules
-    "skipLibCheck": true,
-
-    // File path alias support
-    "baseUrl": ".",
     "paths": {
       "@/*": ["src/*"],
-      "@ponder/common": ["../common/src/index.ts"],
-      "@ponder/client": ["../client/src/index.ts"],
-      "@ponder/utils": ["../utils/src/index.ts"]
+      "@ponder/utils": ["../utils/src"] // Required for end-to-end tests
     }
   }
 }
diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts
deleted file mode 100644
index 122fdc6e..00000000
--- a/packages/core/tsup.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from "tsup";
-
-export default defineConfig({
-  name: "ponder",
-  entry: ["src/index.ts", "src/bin/ponder.ts", "src/drizzle/onchain.ts"],
-  outDir: "dist",
-  format: ["esm"],
-  sourcemap: true,
-  dts: true,
-  clean: true,
-  splitting: true,
-});
diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts
index 5a977f5b..5096910c 100644
--- a/packages/core/vite.config.ts
+++ b/packages/core/vite.config.ts
@@ -6,7 +6,6 @@ export default defineConfig({
   resolve: {
     alias: {
       "@": path.resolve(__dirname, "./src"),
-      "@ponder/common": path.resolve(__dirname, "../common/src"),
       "@ponder/client": path.resolve(__dirname, "../client/src"),
       "@ponder/utils": path.resolve(__dirname, "../utils/src"),
     },
diff --git a/packages/react/build.ts b/packages/react/build.ts
index 10b2b83d..29d36028 100644
--- a/packages/react/build.ts
+++ b/packages/react/build.ts
@@ -1,10 +1,8 @@
-/// <reference types="node" />
-
-import { dirname } from "node:path";
+import path from "node:path";
 import { fileURLToPath } from "node:url";
-import chalk from "chalk";
 import { watch } from "chokidar";
 import { execa } from "execa";
+import pc from "picocolors";
 import { rimraf } from "rimraf";
 
 const PACKAGE_NAME = "@PONDER/REACT";
@@ -12,44 +10,53 @@ const PACKAGE_NAME = "@PONDER/REACT";
 const TSCONFIG = "tsconfig.build.json";
 const WATCH_DIRECTORY = "src";
 
-// Logging utilities
-const prefix = chalk.gray(`[${PACKAGE_NAME}]`);
+const prefix = pc.gray(`[${PACKAGE_NAME}]`);
 const log = {
-  cli: (msg: string) => console.log(`${prefix} ${chalk.magenta("CLI")} ${msg}`),
-  error: (msg: string) =>
-    console.error(`${prefix} ${chalk.red("ERROR")} ${msg}`),
-  success: (msg: string) =>
-    console.log(`${prefix} ${chalk.green("CLI")} ${msg}`),
-  tsc: (msg: string) => console.log(`${prefix} ${chalk.blue("TSC")} ${msg}`),
+  cli: (msg: string) => console.log(`${prefix} ${pc.magenta("CLI")} ${msg}`),
+  error: (msg: string) => console.error(`${prefix} ${pc.red("ERROR")} ${msg}`),
+  success: (msg: string) => console.log(`${prefix} ${pc.green("CLI")} ${msg}`),
+  tsc: (msg: string) => console.log(`${prefix} ${pc.blue("TSC")} ${msg}`),
 };
 
+const isWatchMode =
+  process.argv.includes("--watch") || process.argv.includes("-w");
+
+if (isWatchMode) {
+  watchMode().catch((error) => {
+    log.error(`Watch mode failed: ${error}`);
+    process.exit(1);
+  });
+} else {
+  build().then((success) => {
+    process.exit(success ? 0 : 1);
+  });
+}
+
 async function build() {
   try {
     log.cli("Build start");
     const startTime = Date.now();
 
-    // Clean dist folder
     await rimraf("dist");
     log.cli("Cleaned output folder");
 
-    // Run TypeScript compiler
-    const result = await execa("tsc", ["--project", TSCONFIG], {
+    const tscResult = await execa("tsc", ["--project", TSCONFIG], {
       reject: false,
       stderr: "pipe",
       stdout: "pipe",
     });
 
-    // Handle compiler output
-    const output = `${result.stdout}\n${result.stderr}`
+    `${tscResult.stdout}\n${tscResult.stderr}`
       .trim()
       .split("\n")
-      .filter(Boolean);
-
-    output.forEach((line) => log.tsc(line));
+      .filter(Boolean)
+      .forEach((line) => log.tsc(line));
 
-    if (result.exitCode !== 0) {
+    if (tscResult.exitCode !== 0) {
       log.error("Build failed");
       return false;
+    } else {
+      log.cli("Completed tsc without error");
     }
 
     const duration = Date.now() - startTime;
@@ -65,7 +72,7 @@ async function watchMode() {
   await build();
 
   const watcher = watch(WATCH_DIRECTORY, {
-    cwd: dirname(fileURLToPath(import.meta.url)),
+    cwd: path.dirname(fileURLToPath(import.meta.url)),
     persistent: true,
   });
 
@@ -102,7 +109,6 @@ async function watchMode() {
     log.cli(`Watching for changes in "${WATCH_DIRECTORY}"`);
   });
 
-  // Handle process termination
   process.on("SIGINT", () => {
     watcher.close().then(() => {
       log.cli("Watch mode terminated");
@@ -110,18 +116,3 @@ async function watchMode() {
     });
   });
 }
-
-// Parse command line arguments
-const isWatchMode =
-  process.argv.includes("--watch") || process.argv.includes("-w");
-
-if (isWatchMode) {
-  watchMode().catch((error) => {
-    log.error(`Watch mode failed: ${error}`);
-    process.exit(1);
-  });
-} else {
-  build().then((success) => {
-    process.exit(success ? 0 : 1);
-  });
-}
diff --git a/packages/react/package.json b/packages/react/package.json
index 9c1dc2e5..0d23a598 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -52,9 +52,9 @@
     "@tanstack/react-query": "^5.12.2",
     "@types/node": "^22.10.6",
     "@types/react": "^18.2.0",
-    "chalk": "^5.4.1",
     "chokidar": "^4.0.3",
     "execa": "^8.0.1",
+    "picocolors": "^1.0.0",
     "react": "^18.2.0",
     "rimraf": "^5.0.5",
     "tsx": "^4.19.2"
diff --git a/packages/react/tsconfig.build.json b/packages/react/tsconfig.build.json
index b57495af..6d1591df 100644
--- a/packages/react/tsconfig.build.json
+++ b/packages/react/tsconfig.build.json
@@ -7,6 +7,8 @@
     "declarationDir": "./dist/types",
     "declaration": true,
     "declarationMap": true,
-    "sourceMap": true
+    "sourceMap": true,
+
+    "jsx": "preserve"
   }
 }
diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json
index 38bb51d6..85e4982b 100644
--- a/packages/react/tsconfig.json
+++ b/packages/react/tsconfig.json
@@ -1,8 +1,5 @@
 {
   "extends": "./tsconfig.build.json",
-  "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"],
-  "exclude": [],
-  "compilerOptions": {
-    "jsx": "preserve"
-  }
+  "include": ["src/**/*.ts", "src/**/*.tsx"],
+  "exclude": []
 }
diff --git a/packages/utils/package.json b/packages/utils/package.json
index ac62691a..5d121373 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -49,8 +49,5 @@
     "dotenv": "^16.3.1",
     "tsup": "^8.0.1",
     "vitest": "^1.0.2"
-  },
-  "imports": {
-    "@ponder/common": "../common/index.ts"
   }
 }
diff --git a/packages/utils/src/rateLimit.ts b/packages/utils/src/rateLimit.ts
index 1e97f805..60e94809 100644
--- a/packages/utils/src/rateLimit.ts
+++ b/packages/utils/src/rateLimit.ts
@@ -1,5 +1,5 @@
-import { createQueue } from "@ponder/common";
 import { type Transport, type TransportConfig, createTransport } from "viem";
+import { createQueue } from "./utils/queue.js";
 
 /**
  * @description Creates a rate limited transport that throttles request throughput.
diff --git a/packages/utils/src/utils/promiseWithResolvers.ts b/packages/utils/src/utils/promiseWithResolvers.ts
new file mode 100644
index 00000000..ad51ee43
--- /dev/null
+++ b/packages/utils/src/utils/promiseWithResolvers.ts
@@ -0,0 +1,20 @@
+export type PromiseWithResolvers<TPromise> = {
+  resolve: (arg: TPromise) => void;
+  reject: (error: Error) => void;
+  promise: Promise<TPromise>;
+};
+
+/**
+ * @description Application level polyfill.
+ */
+export const promiseWithResolvers = <
+  TPromise,
+>(): PromiseWithResolvers<TPromise> => {
+  let resolve: (arg: TPromise) => void;
+  let reject: (error: Error) => void;
+  const promise = new Promise<TPromise>((_resolve, _reject) => {
+    resolve = _resolve;
+    reject = _reject;
+  });
+  return { resolve: resolve!, reject: reject!, promise };
+};
diff --git a/packages/utils/src/utils/queue.ts b/packages/utils/src/utils/queue.ts
new file mode 100644
index 00000000..9b80a1b6
--- /dev/null
+++ b/packages/utils/src/utils/queue.ts
@@ -0,0 +1,253 @@
+import {
+  type PromiseWithResolvers,
+  promiseWithResolvers,
+} from "./promiseWithResolvers.js";
+
+export type InnerQueue<returnType, taskType> = {
+  task: taskType;
+  resolve: (arg: returnType) => void;
+  reject: (error: Error) => void;
+}[];
+
+export type Queue<returnType, taskType> = {
+  size: () => number;
+  pending: () => Promise<number>;
+  add: (task: taskType) => Promise<returnType>;
+  clear: () => void;
+  isStarted: () => boolean;
+  start: () => Promise<void>;
+  pause: () => void;
+  onIdle: () => Promise<void>;
+  onEmpty: () => Promise<void>;
+  setParameters: (
+    parameters: Pick<
+      CreateQueueParameters<unknown, unknown>,
+      "frequency" | "concurrency"
+    >,
+  ) => void;
+};
+
+export type CreateQueueParameters<returnType, taskType> = {
+  worker: (task: taskType) => Promise<returnType>;
+  initialStart?: boolean;
+  browser?: boolean;
+} & (
+  | {
+      concurrency: number;
+      frequency: number;
+    }
+  | { concurrency: number; frequency?: undefined }
+  | { concurrency?: undefined; frequency: number }
+);
+
+const validateParameters = ({
+  concurrency,
+  frequency,
+}: Pick<
+  CreateQueueParameters<unknown, unknown>,
+  "frequency" | "concurrency"
+>) => {
+  if (concurrency === undefined && frequency === undefined) {
+    throw new Error(
+      "Invalid queue configuration, must specify either 'concurrency' or 'frequency'.",
+    );
+  }
+
+  if (concurrency !== undefined && concurrency <= 0) {
+    throw new Error(
+      `Invalid value for queue 'concurrency' option. Got ${concurrency}, expected a number greater than zero.`,
+    );
+  }
+
+  if (frequency !== undefined && frequency <= 0) {
+    throw new Error(
+      `Invalid value for queue 'frequency' option. Got ${frequency}, expected a number greater than zero.`,
+    );
+  }
+};
+
+export const createQueue = <returnType, taskType = void>({
+  worker,
+  initialStart = false,
+  browser = true,
+  ..._parameters
+}: CreateQueueParameters<returnType, taskType>): Queue<
+  returnType,
+  taskType
+> => {
+  validateParameters(_parameters);
+
+  const parameters: Pick<
+    CreateQueueParameters<unknown, unknown>,
+    "frequency" | "concurrency"
+  > = _parameters;
+  let queue = new Array<InnerQueue<returnType, taskType>[number]>();
+  let pending = 0;
+  let timestamp = 0;
+  let requests = 0;
+  let isStarted = initialStart;
+
+  let timer: NodeJS.Timeout | undefined;
+
+  let emptyPromiseWithResolvers:
+    | (PromiseWithResolvers<void> & { completed: boolean })
+    | undefined = undefined;
+  let idlePromiseWithResolvers:
+    | (PromiseWithResolvers<void> & { completed: boolean })
+    | undefined = undefined;
+
+  const next = () => {
+    if (!isStarted) return;
+
+    const _timestamp = Date.now();
+
+    if (Math.floor(_timestamp / 1_000) !== timestamp) {
+      requests = 0;
+      timestamp = Math.floor(_timestamp / 1_000);
+    }
+
+    if (timer) return;
+
+    while (
+      (parameters.frequency !== undefined
+        ? requests < parameters.frequency
+        : true) &&
+      (parameters.concurrency !== undefined
+        ? pending < parameters.concurrency
+        : true) &&
+      queue.length > 0
+    ) {
+      const { task, resolve, reject } = queue.shift()!;
+
+      requests++;
+      pending++;
+
+      worker(task)
+        .then(resolve)
+        .catch(reject)
+        .finally(() => {
+          pending--;
+
+          if (
+            idlePromiseWithResolvers !== undefined &&
+            queue.length === 0 &&
+            pending === 0
+          ) {
+            idlePromiseWithResolvers.resolve();
+            idlePromiseWithResolvers.completed = true;
+          }
+
+          browser ? next() : process.nextTick(next);
+        });
+
+      if (emptyPromiseWithResolvers !== undefined && queue.length === 0) {
+        emptyPromiseWithResolvers.resolve();
+        emptyPromiseWithResolvers.completed = true;
+      }
+    }
+
+    if (
+      parameters.frequency !== undefined &&
+      requests >= parameters.frequency
+    ) {
+      timer = setTimeout(
+        () => {
+          timer = undefined;
+          next();
+        },
+        1_000 - (_timestamp % 1_000),
+      );
+      return;
+    }
+  };
+
+  return {
+    size: () => queue.length,
+    pending: () => {
+      if (browser) {
+        return new Promise<number>((resolve) =>
+          setTimeout(() => resolve(pending)),
+        );
+      } else {
+        return new Promise<number>((resolve) =>
+          setImmediate(() => resolve(pending)),
+        );
+      }
+    },
+    add: (task: taskType) => {
+      const { promise, resolve, reject } = promiseWithResolvers<returnType>();
+      queue.push({ task, resolve, reject });
+
+      next();
+
+      return promise.catch((error) => {
+        Error.captureStackTrace(error);
+        throw error;
+      });
+    },
+    clear: () => {
+      queue = new Array<InnerQueue<returnType, taskType>[number]>();
+      clearTimeout(timer);
+      timer = undefined;
+    },
+    isStarted: () => isStarted,
+    start: () => {
+      if (browser) {
+        return new Promise<number>((resolve) =>
+          setTimeout(() => resolve(pending)),
+        ).then(() => {
+          isStarted = true;
+          next();
+        });
+      } else {
+        return new Promise<number>((resolve) =>
+          process.nextTick(() => resolve(pending)),
+        ).then(() => {
+          isStarted = true;
+          next();
+        });
+      }
+    },
+    pause: () => {
+      isStarted = false;
+    },
+    onIdle: () => {
+      if (
+        idlePromiseWithResolvers === undefined ||
+        idlePromiseWithResolvers.completed
+      ) {
+        if (queue.length === 0 && pending === 0) return Promise.resolve();
+
+        idlePromiseWithResolvers = {
+          ...promiseWithResolvers<void>(),
+          completed: false,
+        };
+      }
+      return idlePromiseWithResolvers.promise;
+    },
+    onEmpty: () => {
+      if (
+        emptyPromiseWithResolvers === undefined ||
+        emptyPromiseWithResolvers.completed
+      ) {
+        if (queue.length === 0) return Promise.resolve();
+
+        emptyPromiseWithResolvers = {
+          ...promiseWithResolvers<void>(),
+          completed: false,
+        };
+      }
+      return emptyPromiseWithResolvers.promise;
+    },
+    setParameters: (_parameters) => {
+      validateParameters(_parameters);
+
+      if ("frequency" in _parameters) {
+        parameters.frequency = _parameters.frequency;
+      }
+      if ("concurrency" in _parameters) {
+        parameters.concurrency = _parameters.concurrency;
+      }
+    },
+  } as Queue<returnType, taskType>;
+};
diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json
index 85b4e4eb..2d980d21 100644
--- a/packages/utils/tsconfig.json
+++ b/packages/utils/tsconfig.json
@@ -33,7 +33,6 @@
     ],
 
     // Skip type checking for node modules
-    "skipLibCheck": true,
-    "paths": { "@ponder/common": ["../common/src/index.ts"] }
+    "skipLibCheck": true
   }
 }
diff --git a/packages/utils/vite.config.ts b/packages/utils/vite.config.ts
index 824df6ca..0197945d 100644
--- a/packages/utils/vite.config.ts
+++ b/packages/utils/vite.config.ts
@@ -2,11 +2,6 @@ import path from "node:path";
 import { defineConfig } from "vitest/config";
 
 export default defineConfig({
-  resolve: {
-    alias: {
-      "@ponder/common": path.resolve(__dirname, "../common/src"),
-    },
-  },
   test: {
     globalSetup: ["src/_test/globalSetup.ts"],
   },
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2206e6fc..32637e68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -567,7 +567,7 @@ importers:
     dependencies:
       forge-std:
         specifier: github:foundry-rs/forge-std
-        version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/999be66ec94c78090087f674bbf52ce1cca12b75
+        version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545
 
   examples/with-foundry/ponder:
     dependencies:
@@ -789,21 +789,6 @@ importers:
         specifier: ^8.0.1
         version: 8.0.1(postcss@8.4.32)(ts-node@10.9.2(@types/node@22.10.6)(typescript@5.3.3))(typescript@5.3.3)
 
-  packages/common:
-    devDependencies:
-      '@types/node':
-        specifier: ^20.11.24
-        version: 20.11.24
-      dotenv:
-        specifier: ^16.3.1
-        version: 16.4.5
-      tsup:
-        specifier: ^8.0.1
-        version: 8.0.1(postcss@8.4.32)(ts-node@10.9.2(@types/node@20.11.24)(typescript@5.3.3))(typescript@5.3.3)
-      vitest:
-        specifier: ^1.0.2
-        version: 1.0.2(@types/node@20.11.24)
-
   packages/core:
     dependencies:
       '@babel/code-frame':
@@ -909,6 +894,9 @@ importers:
       '@pgsql/types':
         specifier: 16.0.0
         version: 16.0.0
+      '@ponder/client':
+        specifier: workspace:*
+        version: link:../client
       '@types/babel__code-frame':
         specifier: ^7.0.6
         version: 7.0.6
@@ -930,15 +918,18 @@ importers:
       '@wagmi/cli':
         specifier: ^1.5.2
         version: 1.5.2(typescript@5.3.3)
+      chokidar:
+        specifier: ^4.0.3
+        version: 4.0.3
       execa:
         specifier: ^8.0.1
         version: 8.0.1
       rimraf:
         specifier: ^5.0.5
         version: 5.0.5
-      tsup:
-        specifier: ^8.0.1
-        version: 8.0.1(postcss@8.4.32)(ts-node@10.9.2(@types/node@20.11.24)(typescript@5.3.3))(typescript@5.3.3)
+      tsx:
+        specifier: ^4.19.2
+        version: 4.19.2
       vitest:
         specifier: ^1.0.2
         version: 1.0.2(@types/node@20.11.24)
@@ -1030,15 +1021,15 @@ importers:
       '@types/react':
         specifier: ^18.2.0
         version: 18.2.46
-      chalk:
-        specifier: ^5.4.1
-        version: 5.4.1
       chokidar:
         specifier: ^4.0.3
         version: 4.0.3
       execa:
         specifier: ^8.0.1
         version: 8.0.1
+      picocolors:
+        specifier: ^1.0.0
+        version: 1.0.0
       react:
         specifier: ^18.2.0
         version: 18.2.0
@@ -5292,9 +5283,9 @@ packages:
   forever-agent@0.6.1:
     resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
 
-  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/999be66ec94c78090087f674bbf52ce1cca12b75:
-    resolution: {tarball: https://codeload.github.com/foundry-rs/forge-std/tar.gz/999be66ec94c78090087f674bbf52ce1cca12b75}
-    version: 1.9.5
+  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545:
+    resolution: {tarball: https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545}
+    version: 1.9.6
 
   form-data@2.3.3:
     resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
@@ -7826,7 +7817,6 @@ packages:
 
   shikiji@0.6.10:
     resolution: {integrity: sha512-WE+A5Y2ntM5hL3iJQujk97qr5Uj7PSIRXpQfrZ6h+JWPXZ8KBEDhFXc4lqNriaRq1WGOVPUT83XMOzmHiH3W8A==}
-    deprecated: Shikiji is merged back to Shiki v1.0, please migrate over to get the latest updates
 
   side-channel@1.0.4:
     resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
@@ -13565,7 +13555,7 @@ snapshots:
 
   forever-agent@0.6.1: {}
 
-  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/999be66ec94c78090087f674bbf52ce1cca12b75: {}
+  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545: {}
 
   form-data@2.3.3:
     dependencies:
@@ -14137,7 +14127,7 @@ snapshots:
       '@alcalzone/ansi-tokenize': 0.1.3
       ansi-escapes: 6.2.0
       auto-bind: 5.0.1
-      chalk: 5.3.0
+      chalk: 5.4.1
       cli-boxes: 3.0.0
       cli-cursor: 4.0.0
       cli-truncate: 3.1.0
@@ -14763,7 +14753,7 @@ snapshots:
 
   log-symbols@5.1.0:
     dependencies:
-      chalk: 5.3.0
+      chalk: 5.4.1
       is-unicode-supported: 1.3.0
 
   log-update@6.0.0:
@@ -15931,7 +15921,7 @@ snapshots:
 
   ora@6.3.1:
     dependencies:
-      chalk: 5.3.0
+      chalk: 5.4.1
       cli-cursor: 4.0.0
       cli-spinners: 2.9.2
       is-interactive: 2.0.0
@@ -15943,7 +15933,7 @@ snapshots:
 
   ora@7.0.1:
     dependencies:
-      chalk: 5.3.0
+      chalk: 5.4.1
       cli-cursor: 4.0.0
       cli-spinners: 2.9.2
       is-interactive: 2.0.0
diff --git a/tsconfig.base.json b/tsconfig.base.json
index a284fd25..12d2a36e 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -18,14 +18,15 @@
     "noUnusedParameters": true, // Not enabled by default in `strict` mode.
     "useDefineForClassFields": true, // Not enabled by default in `strict` mode unless we bump `target` to ES2022.
     "useUnknownInCatchVariables": true,
+    "forceConsistentCasingInFileNames": true,
 
     // JavaScript support
     "allowJs": false,
     "checkJs": false,
 
     // Interop constraints
-    "forceConsistentCasingInFileNames": true,
     "verbatimModuleSyntax": true,
+    "resolveJsonModule": true,
 
     // Language and environment
     "moduleResolution": "NodeNext",


Test patch:
diff --git a/packages/common/src/debounce.test.ts b/packages/common/src/debounce.test.ts
deleted file mode 100644
index a3af15c2..00000000
--- a/packages/common/src/debounce.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { expect, test, vi } from "vitest";
-import { wait } from "../../core/src/utils/wait.js";
-import { debounce } from "./debounce.js";
-
-test("invoke function after timeout", async () => {
-  const fun = vi.fn(() => {});
-  const { call } = debounce(10, fun);
-
-  call();
-
-  expect(fun).toHaveBeenCalledTimes(0);
-
-  await wait(20);
-
-  expect(fun).toHaveBeenCalledTimes(1);
-});
-
-test("skips invocation during timeout", async () => {
-  const fun = vi.fn(() => {});
-  const { call } = debounce(10, fun);
-
-  call();
-  call();
-  call();
-  call();
-  call();
-
-  await wait(20);
-
-  expect(fun).toHaveBeenCalledTimes(1);
-});
-
-test("updates arguments during timeout", async () => {
-  const fun = vi.fn((n: number) => {
-    n;
-  });
-  const { call } = debounce(10, fun);
-
-  call(1);
-  call(2);
-  call(3);
-  call(4);
-  call(5);
-
-  await wait(20);
-
-  expect(fun).toHaveBeenCalledTimes(1);
-  expect(fun).toHaveBeenCalledWith(5);
-});
-
-test("cancel", async () => {
-  const fun = vi.fn(() => {});
-  const { call, cancel } = debounce(10, fun);
-
-  call();
-  cancel();
-
-  expect(fun).toHaveBeenCalledTimes(0);
-
-  await wait(20);
-
-  expect(fun).toHaveBeenCalledTimes(0);
-});
diff --git a/packages/common/src/poll.test.ts b/packages/common/src/poll.test.ts
deleted file mode 100644
index d44bc9ee..00000000
--- a/packages/common/src/poll.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { expect, test, vi } from "vitest";
-import { wait } from "../../core/src/utils/wait.js";
-import { poll } from "./poll.js";
-
-test("poll", async () => {
-  const fn = vi.fn(() => {});
-
-  const unpoll = poll(fn, { interval: 10 });
-
-  await wait(12);
-
-  expect(fn).toHaveBeenCalledTimes(1);
-
-  unpoll();
-});
-
-test("unpoll", async () => {
-  const fn = vi.fn(() => {});
-
-  const unpoll = poll(fn, { interval: 10 });
-
-  unpoll();
-
-  expect(fn).toHaveBeenCalledTimes(0);
-
-  await wait(12);
-
-  expect(fn).toHaveBeenCalledTimes(0);
-});
diff --git a/packages/common/src/retry.test-d.ts b/packages/common/src/retry.test-d.ts
deleted file mode 100644
index 97782c53..00000000
--- a/packages/common/src/retry.test-d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { assertType, test } from "vitest";
-import { retry } from "./retry.js";
-
-test("returnType", () => {
-  const callback = () => Promise.resolve(1 as const);
-  const out = retry(callback);
-  assertType<Promise<1>>(out.promise);
-});
diff --git a/packages/common/src/retry.test.ts b/packages/common/src/retry.test.ts
deleted file mode 100644
index 0a171fd5..00000000
--- a/packages/common/src/retry.test.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { expect, test } from "vitest";
-import { retry } from "./retry.js";
-
-test("returns with no errors", async () => {
-  const callback = () => Promise.resolve(1);
-  const out = retry(callback);
-  expect(await out.promise).toBe(1);
-});
-
-test("retries", async () => {
-  let i = 0;
-
-  await retry(
-    () => {
-      i++;
-      if (i === 1) return Promise.reject();
-      return Promise.resolve();
-    },
-    { timeout: 1 },
-  ).promise;
-
-  expect(i).toBe(2);
-});
-
-test("cancel", async () => {
-  let rejected = false;
-
-  const out = retry(
-    () => {
-      return Promise.reject();
-    },
-    {
-      timeout: 1_000,
-      exponential: true,
-    },
-  );
-
-  out.cancel();
-
-  await out.promise.catch((e) => {
-    if (e.message === "Retry canceled") rejected = true;
-  });
-
-  expect(rejected).toBe(true);
-});
-
-test.todo("calculates timeout");
-
-test("throws error", async () => {
-  let i = 0;
-  let rejected = false;
-
-  const out = retry(
-    async () => {
-      i++;
-
-      throw Error();
-    },
-    {
-      timeout: 1,
-      exponential: false,
-    },
-  ).promise;
-
-  await out.catch(() => {
-    rejected = true;
-  });
-
-  expect(i).toBe(4);
-  expect(rejected).toBe(true);
-});
diff --git a/packages/core/src/sync/index.test.ts b/packages/core/src/sync/index.test.ts
index 66d62543..0c639fd7 100644
--- a/packages/core/src/sync/index.test.ts
+++ b/packages/core/src/sync/index.test.ts
@@ -20,9 +20,9 @@ import {
 } from "@/utils/checkpoint.js";
 import { drainAsyncGenerator } from "@/utils/generators.js";
 import type { Interval } from "@/utils/interval.js";
+import { promiseWithResolvers } from "@/utils/promiseWithResolvers.js";
 import { createRequestQueue } from "@/utils/requestQueue.js";
 import { _eth_getBlockByNumber } from "@/utils/rpc.js";
-import { promiseWithResolvers } from "@ponder/common";
 import { beforeEach, expect, test, vi } from "vitest";
 import { getFragments } from "./fragments.js";
 import {
diff --git a/packages/common/src/dedupe.ts b/packages/core/src/utils/dedupe.ts
similarity index 100%
rename from packages/common/src/dedupe.ts
rename to packages/core/src/utils/dedupe.ts
diff --git a/packages/core/src/utils/generators.test.ts b/packages/core/src/utils/generators.test.ts
index c4620610..05516c3f 100644
--- a/packages/core/src/utils/generators.test.ts
+++ b/packages/core/src/utils/generators.test.ts
@@ -1,4 +1,4 @@
-import { promiseWithResolvers } from "@ponder/common";
+import { promiseWithResolvers } from "@/utils/promiseWithResolvers.js";
 import { expect, test } from "vitest";
 import { bufferAsyncGenerator, mergeAsyncGenerators } from "./generators.js";
 
diff --git a/packages/core/src/utils/mutex.test.ts b/packages/core/src/utils/mutex.test.ts
index 8645d1c5..e69e5ed3 100644
--- a/packages/core/src/utils/mutex.test.ts
+++ b/packages/core/src/utils/mutex.test.ts
@@ -1,4 +1,4 @@
-import { promiseWithResolvers } from "@ponder/common";
+import { promiseWithResolvers } from "@/utils/promiseWithResolvers.js";
 import { expect, test, vi } from "vitest";
 import { createMutex, mutex } from "./mutex.js";
 
diff --git a/packages/utils/src/utils/promiseWithResolvers.test-d.ts b/packages/utils/src/utils/promiseWithResolvers.test-d.ts
new file mode 100644
index 00000000..33fca38e
--- /dev/null
+++ b/packages/utils/src/utils/promiseWithResolvers.test-d.ts
@@ -0,0 +1,12 @@
+import { assertType, test } from "vitest";
+import { promiseWithResolvers } from "./promiseWithResolvers.js";
+
+test("resolve type", () => {
+  const { resolve } = promiseWithResolvers<number>();
+  assertType<(arg: number) => void>(resolve);
+});
+
+test("promise type", () => {
+  const { promise } = promiseWithResolvers<number>();
+  assertType<Promise<number>>(promise);
+});
diff --git a/packages/utils/src/utils/promiseWithResolvers.test.ts b/packages/utils/src/utils/promiseWithResolvers.test.ts
new file mode 100644
index 00000000..3a13bf4b
--- /dev/null
+++ b/packages/utils/src/utils/promiseWithResolvers.test.ts
@@ -0,0 +1,26 @@
+import { expect, test } from "vitest";
+import { promiseWithResolvers } from "./promiseWithResolvers.js";
+
+test("resolves", async () => {
+  const { promise, resolve } = promiseWithResolvers<number>();
+
+  resolve(1);
+
+  const value = await promise;
+
+  expect(value).toBe(1);
+});
+
+test("rejects", async () => {
+  let rejected = false;
+
+  const { promise, reject } = promiseWithResolvers();
+
+  promise.catch(() => {
+    rejected = true;
+  });
+
+  await Promise.reject().catch(reject);
+
+  expect(rejected).toBe(true);
+});
diff --git a/packages/utils/src/utils/queue.test-d.ts b/packages/utils/src/utils/queue.test-d.ts
new file mode 100644
index 00000000..0ece93b1
--- /dev/null
+++ b/packages/utils/src/utils/queue.test-d.ts
@@ -0,0 +1,11 @@
+import { assertType, test } from "vitest";
+import { createQueue } from "./queue.js";
+
+test("add type", () => {
+  const queue = createQueue({
+    concurrency: 1,
+    worker: (_arg: "a" | "b" | "c") => Promise.resolve(),
+  });
+
+  assertType<(task: "a" | "b" | "c") => Promise<void>>(queue.add);
+});
diff --git a/packages/utils/src/utils/queue.test.ts b/packages/utils/src/utils/queue.test.ts
new file mode 100644
index 00000000..3df42fe6
--- /dev/null
+++ b/packages/utils/src/utils/queue.test.ts
@@ -0,0 +1,410 @@
+import { expect, test, vi } from "vitest";
+import { promiseWithResolvers } from "./promiseWithResolvers.js";
+import { createQueue } from "./queue.js";
+
+test("add resolves", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    initialStart: true,
+    browser: false,
+    worker: () => Promise.resolve(1),
+  });
+
+  const promise = queue.add();
+
+  expect(await promise).toBe(1);
+});
+
+test("add rejects", async () => {
+  let rejected = false;
+
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.reject(),
+  });
+
+  const promise = queue.add();
+
+  await queue.start();
+
+  await promise.catch(() => {
+    rejected = true;
+  });
+
+  expect(rejected).toBe(true);
+});
+
+test("size", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.add();
+
+  expect(queue.size()).toBe(1);
+
+  await queue.start();
+
+  expect(queue.size()).toBe(0);
+});
+
+test("pending", async () => {
+  const { promise, resolve } = promiseWithResolvers<void>();
+
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    initialStart: true,
+    browser: false,
+    worker: () => promise,
+  });
+
+  queue.add();
+
+  expect(await queue.pending()).toBe(1);
+
+  resolve();
+
+  expect(await queue.pending()).toBe(0);
+});
+
+test("clear", () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.add();
+  queue.add();
+  queue.add();
+
+  queue.clear();
+
+  expect(queue.size()).toBe(0);
+});
+
+test("clear timer", async () => {
+  const queue = createQueue({
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.add();
+  queue.add();
+  queue.add();
+
+  await queue.start();
+  queue.clear();
+
+  await queue.onIdle();
+
+  expect(queue.size()).toBe(0);
+  expect(await queue.pending()).toBe(0);
+});
+
+test("isStarted", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  expect(queue.isStarted()).toBe(false);
+
+  await queue.start();
+
+  expect(queue.isStarted()).toBe(true);
+});
+
+test("initial start", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    initialStart: true,
+    worker: () => Promise.resolve(),
+  });
+
+  expect(queue.isStarted()).toBe(true);
+
+  await queue.add();
+
+  expect(queue.size()).toBe(0);
+});
+
+test("start", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  const promise = queue.add();
+
+  expect(queue.size()).toBe(1);
+
+  await queue.start();
+
+  expect(queue.isStarted()).toBe(true);
+
+  await promise;
+
+  expect(queue.size()).toBe(0);
+});
+
+test("pause", () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    initialStart: true,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.pause();
+
+  queue.add();
+
+  expect(queue.size()).toBe(1);
+});
+
+test.todo("restart");
+
+test("onIdle short loop", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  await queue.onIdle();
+});
+
+test("onIdle", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.add();
+
+  const promise = queue.onIdle();
+
+  await queue.start();
+
+  await promise;
+});
+
+test("onIdle twice", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.add();
+
+  queue.onIdle();
+
+  await queue.start();
+
+  queue.pause();
+
+  queue.add();
+
+  const promise = queue.onIdle();
+
+  await queue.start();
+
+  await promise;
+});
+
+test("onEmpty short loop", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  await queue.onEmpty();
+});
+
+test("onEmpty", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.add();
+
+  const promise = queue.onEmpty();
+
+  await queue.start();
+
+  await promise;
+});
+
+test("onEmpty twice", async () => {
+  const queue = createQueue({
+    concurrency: 1,
+    frequency: 1,
+    browser: false,
+    worker: () => Promise.resolve(),
+  });
+
+  queue.add();
+
+  queue.onEmpty();
+
+  await queue.start();
+
+  queue.pause();
+
+  queue.add();
+
+  const promise = queue.onEmpty();
+
+  await queue.start();
+
+  await promise;
+});
+
+test("concurrency", async () => {
+  const func = vi.fn(() => Promise.resolve());
+
+  const queue = createQueue({
+    concurrency: 2,
+    frequency: 5,
+    browser: false,
+    worker: func,
+  });
+
+  queue.add();
+  queue.add();
+  queue.add();
+  queue.add();
+
+  await queue.start();
+  queue.pause();
+
+  expect(queue.size()).toBe(2);
+  expect(func).toHaveBeenCalledTimes(2);
+});
+
+test("frequency", async () => {
+  const func = vi.fn(() => Promise.resolve());
+
+  const queue = createQueue({
+    frequency: 2,
+    concurrency: 5,
+    browser: false,
+    worker: func,
+  });
+
+  queue.add();
+  queue.add();
+  queue.add();
+  queue.add();
+
+  await queue.start();
+
+  expect(queue.size()).toBe(2);
+  expect(func).toHaveBeenCalledTimes(2);
+
+  await new Promise((resolve) => setTimeout(resolve, 1_010));
+
+  expect(queue.size()).toBe(0);
+  expect(func).toHaveBeenCalledTimes(4);
+});
+
+/**
+ * Two queues running at the same time should alternate between events.
+ * One queue running all its event in a row would mean the event loop
+ * is being "starved".
+ */
+test("event loop", async () => {
+  const out: number[] = [];
+
+  const queue1 = createQueue({
+    concurrency: 1,
+    browser: false,
+    worker: () => {
+      out.push(1);
+      return Promise.resolve();
+    },
+  });
+
+  const queue2 = createQueue({
+    concurrency: 1,
+    browser: false,
+    worker: () => {
+      out.push(2);
+      return Promise.resolve();
+    },
+  });
+
+  for (let i = 0; i < 10; i++) {
+    queue1.add();
+    queue2.add();
+  }
+
+  await Promise.all([queue1.start(), queue2.start()]);
+  await Promise.all([queue1.onIdle(), queue2.onIdle()]);
+
+  const expectedOut: number[] = [];
+
+  for (let i = 0; i < 10; i++) {
+    expectedOut.push(1, 2);
+  }
+
+  expect(out).toStrictEqual(expectedOut);
+});
+
+test("update parameters", async () => {
+  const func = vi.fn(() => Promise.resolve());
+
+  const queue = createQueue({
+    concurrency: 2,
+    frequency: 5,
+    browser: false,
+    worker: func,
+  });
+
+  queue.add();
+  queue.add();
+  queue.add();
+  queue.add();
+  queue.add();
+  queue.add();
+
+  await queue.start();
+  queue.pause();
+
+  expect(queue.size()).toBe(4);
+  expect(func).toHaveBeenCalledTimes(2);
+
+  queue.setParameters({ concurrency: undefined, frequency: 8 });
+
+  await queue.start();
+  queue.pause();
+
+  expect(queue.size()).toBe(0);
+  expect(func).toHaveBeenCalledTimes(6);
+});