Skip to content
5 changes: 5 additions & 0 deletions .changeset/brown-spies-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensdb-sdk": minor
---

Added `destroy()` method to `EnsDbReader` class that allows cleaning up database connection resources when the connection is no longer needed.
7 changes: 6 additions & 1 deletion apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
SWRCache,
} from "@ensnode/ensnode-sdk";

import { ensDbClient } from "@/lib/ensdb/singleton";
import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";

Expand All @@ -30,6 +29,12 @@ export const indexingStatusCache = lazyProxy<IndexingStatusCache>(
() =>
new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async function loadIndexingStatusSnapshot() {
// Async import `di` here to avoid circular dependency between this cache module and the DI container module.
// NOTE: It will not be required soon, as we plan to create a factory function for this cache
// that accepts the necessary dependencies as parameters, instead of importing from the DI container.
const di = await import("@/di").then((mod) => mod.default);
const { ensDbClient } = di.context;

Comment thread
tk-o marked this conversation as resolved.
Comment thread
tk-o marked this conversation as resolved.
try {
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

Expand Down
12 changes: 6 additions & 6 deletions apps/ensapi/src/cache/stack-info.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from "@ensnode/ensnode-sdk";

import { buildEnsApiPublicConfig } from "@/config/config.schema";
import { ensDbClient } from "@/lib/ensdb/singleton";
import { lazyProxy } from "@/lib/lazy";
import logger from "@/lib/logger";

Expand Down Expand Up @@ -41,6 +40,12 @@ export const stackInfoCache = lazyProxy<EnsNodeStackInfoCache>(
() =>
new SWRCache<EnsNodeStackInfo>({
fn: async function loadEnsNodeStackInfo() {
// Async import `di` here to avoid circular dependency between this cache module and the DI container module.
// NOTE: It will not be required soon, as we plan to create a factory function for this cache
// that accepts the necessary dependencies as parameters, instead of importing from the DI container.
const di = await import("@/di").then((mod) => mod.default);
const { ensDbClient } = di.context;

Comment thread
tk-o marked this conversation as resolved.
try {
Comment thread
tk-o marked this conversation as resolved.
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

Expand All @@ -54,11 +59,6 @@ export const stackInfoCache = lazyProxy<EnsNodeStackInfoCache>(
throw new Error("Indexing Metadata Context was uninitialized in ENSDb.");
}

// Async import `di` here to avoid circular dependency between this cache module and the DI container module.
// NOTE: It will not be required soon, as we plan to create a factory function for this cache
// that accepts the necessary dependencies as parameters, instead of importing from the DI container.
const di = await import("@/di").then((mod) => mod.default);

const ensIndexerStackInfo = indexingMetadataContext.stackInfo;
const ensNodeStackInfo = buildEnsNodeStackInfo(
buildEnsApiPublicConfig(di.context.ensApiConfig, ensIndexerStackInfo.ensIndexer),
Expand Down
16 changes: 1 addition & 15 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ensApiVersionInfo } from "@/lib/version-info";

vi.mock("@/lib/ensdb/singleton", () => ({
ensDbClient: {
getIndexingMetadataContext: vi.fn(async () => indexingMetadataContextInitialized),
},
}));

vi.mock("@/config/ensdb-config", () => ({
default: {
ensDbUrl: "postgresql://user:password@localhost:5432/mydb",
ensIndexerSchemaName: "ensindexer_0",
},
}));

import { ENSNamespaceIds } from "@ensnode/ensnode-sdk";

import {
Expand All @@ -25,6 +10,7 @@ import {
import { BASE_ENV, indexingMetadataContextInitialized } from "@/config/config.schema.mock";
import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import logger from "@/lib/logger";
import { ensApiVersionInfo } from "@/lib/version-info";

vi.mock("@/lib/logger", () => ({
default: {
Expand Down
76 changes: 63 additions & 13 deletions apps/ensapi/src/config/config.singleton.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import di from "@/di";

vi.mock("@/lib/logger", () => ({
default: {
error: vi.fn(),
info: vi.fn(),
},
makeLogger: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
})),
}));

vi.mock("@ensnode/ensdb-sdk", async (importOriginal) => {
class MockEnsDbReader {
ensDb = {};
ensIndexerSchema = {};
ensIndexerSchemaName = "ensindexer_test";
async isHealthy() {
return true;
}
}

const mod = await importOriginal<typeof import("@ensnode/ensdb-sdk")>();
return {
...mod,
EnsDbReader: MockEnsDbReader,
};
});
Comment thread
tk-o marked this conversation as resolved.
Comment thread
tk-o marked this conversation as resolved.

vi.mock("@/cache/indexing-status.cache", () => ({
indexingStatusCache: {
read: vi.fn().mockResolvedValue({}),
destroy: vi.fn(),
},
}));

vi.mock("@/cache/stack-info.cache", () => ({
stackInfoCache: {
read: vi.fn().mockResolvedValue({}),
destroy: vi.fn(),
peek: vi.fn().mockReturnValue({
ensIndexer: { namespace: "mainnet" },
}),
},
}));

vi.mock("@/cache/referral-program-edition-set.cache", () => ({
referralProgramEditionConfigSetCache: {
read: vi.fn().mockResolvedValue({}),
destroy: vi.fn(),
},
}));

vi.mock("viem", async (importOriginal) => {
const mod = await importOriginal<typeof import("viem")>();
return {
...mod,
createPublicClient: vi.fn().mockReturnValue({
getBlockNumber: vi.fn().mockResolvedValue(1n),
}),
};
});

const VALID_ENSDB_URL = "postgresql://user:password@localhost:5432/mydb";
const VALID_ENSINDEXER_SCHEMA_NAME = "ensindexer_test";

describe("ensdb singleton bootstrap", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("ENSDB_URL", VALID_ENSDB_URL);
vi.stubEnv("ENSINDEXER_SCHEMA_NAME", VALID_ENSINDEXER_SCHEMA_NAME);
vi.stubEnv("RPC_URL_1", "https://rpc.example.com");
});

afterEach(() => {
di.destroy();
vi.unstubAllEnvs();
});

it("constructs EnsDbReader from real env wiring without errors", async () => {
const { ensDbClient, ensDb, ensIndexerSchema } = await import("@/lib/ensdb/singleton");

// ensDbClient is a lazyProxy — construction is deferred until first property access.
// Accessing a property triggers EnsDbReader construction; verify it succeeds.
await di.init();
const { ensDbClient, ensDb, ensIndexerSchema } = di.context;
expect(ensDbClient.ensIndexerSchemaName).toBe(VALID_ENSINDEXER_SCHEMA_NAME);
expect(ensDb).toBeDefined();
expect(ensIndexerSchema).toBeDefined();
Expand All @@ -38,10 +94,7 @@ describe("ensdb singleton bootstrap", () => {
const { default: logger } = await import("@/lib/logger");

vi.stubEnv("ENSDB_URL", "");
// ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
// which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
const { ensDbClient } = await import("@/lib/ensdb/singleton");
expect(() => ensDbClient.ensDb).toThrow("process.exit");
await expect(di.init()).rejects.toThrow("process.exit");

Comment thread
tk-o marked this conversation as resolved.
expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
Expand All @@ -55,10 +108,7 @@ describe("ensdb singleton bootstrap", () => {
const { default: logger } = await import("@/lib/logger");

vi.stubEnv("ENSINDEXER_SCHEMA_NAME", "");
// ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
// which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
const { ensDbClient } = await import("@/lib/ensdb/singleton");
expect(() => ensDbClient.ensDb).toThrow("process.exit");
await expect(di.init()).rejects.toThrow("process.exit");

Comment thread
tk-o marked this conversation as resolved.
expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
Expand Down
10 changes: 2 additions & 8 deletions apps/ensapi/src/config/ensdb-config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { type EnsDbConfig, validateEnsDbConfig } from "@ensnode/ensdb-sdk";
import type { Unvalidated } from "@ensnode/ensnode-sdk";
import type { EnsDbEnvironment } from "@ensnode/ensnode-sdk/internal";

import { lazyProxy } from "@/lib/lazy";
import logger from "@/lib/logger";

/**
* Build ENSDb config from environment variables for ENSApi app.
*
* Exits the process if the configuration is invalid, logging the error details.
*/
export function buildEnsDbConfigFromEnvironment(env: NodeJS.ProcessEnv): EnsDbConfig {
export function buildEnsDbConfigFromEnvironment(env: EnsDbEnvironment): EnsDbConfig {
const unvalidatedConfig = {
ensDbUrl: env.ENSDB_URL,
ensIndexerSchemaName: env.ENSINDEXER_SCHEMA_NAME,
Expand All @@ -23,9 +23,3 @@ export function buildEnsDbConfigFromEnvironment(env: NodeJS.ProcessEnv): EnsDbCo
process.exit(1);
}
}

// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
const ensDbConfig = lazyProxy<EnsDbConfig>(() => buildEnsDbConfigFromEnvironment(process.env));

export default ensDbConfig;
78 changes: 65 additions & 13 deletions apps/ensapi/src/di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { createPublicClient, fallback, http, type PublicClient } from "viem";

import { type ENSNamespaceId, getENSRootChainId } from "@ensnode/datasources";
import type { EnsDbConfig, EnsDbReader } from "@ensnode/ensdb-sdk";
import { type EnsDbConfig, EnsDbReader } from "@ensnode/ensdb-sdk";
import type { EnsNodeStackInfo } from "@ensnode/ensnode-sdk";
import type { RpcConfig } from "@ensnode/ensnode-sdk/internal";

Expand All @@ -15,9 +15,8 @@
import { stackInfoCache } from "@/cache/stack-info.cache";
import type { EnsApiConfig } from "@/config/config.schema";
import { buildConfigFromEnvironment, buildRootChainRpcConfig } from "@/config/config.schema";
import ensDbConfig from "@/config/ensdb-config";
import { buildEnsDbConfigFromEnvironment } from "@/config/ensdb-config";
import type { EnsApiEnvironment } from "@/config/environment";
import { ensDbClient } from "@/lib/ensdb/singleton";
import { makeLogger } from "@/lib/logger";

const logger = makeLogger("di");
Expand Down Expand Up @@ -106,13 +105,18 @@

get ensDbConfig(): EnsDbConfig {
if (instances.ensDbConfig === undefined) {
instances.ensDbConfig = ensDbConfig;
instances.ensDbConfig = buildEnsDbConfigFromEnvironment(ensApiEnvironment);
}
return instances.ensDbConfig;
},

get ensDbClient(): EnsDbReader {
return ensDbClient;
if (instances.ensDbClient === undefined) {
const { ensDbUrl, ensIndexerSchemaName } = context.ensDbConfig;
instances.ensDbClient = new EnsDbReader(ensDbUrl, ensIndexerSchemaName);
}

return instances.ensDbClient;
},
Comment thread
tk-o marked this conversation as resolved.

get ensDb(): EnsDbReader["ensDb"] {
Expand Down Expand Up @@ -240,9 +244,10 @@

/**
* Initializes the DI container by loading the context and initializing
* necessary resources.
* necessary resources that need to be evaluated eagerly, such as
* ENSDb client, caches, RPC client for the ENS Root Chain, etc.
*/
init(): void {
async init(): Promise<void> {
if (this._context) {
throw new Error(
Comment thread
tk-o marked this conversation as resolved.
"DI context has already been initialized. If you want to re-initialize, call `di.destroy()` first to clean up resources.",
Expand All @@ -252,12 +257,55 @@
// Load the DI context
this.loadContext();

logger.info("Initializing caches");
void Promise.all([
this.context.indexingStatusCache.read(),
this.context.stackInfoCache.read(),
this.context.referralProgramEditionConfigSetCache.read(),
]).then(() => logger.info("Caches initialized"));
try {
// Initialize the ENSDb client and verify connectivity to the database.
logger.info("Initializing ENSDb client and verifying connectivity to ENSDb");
await this.context.ensDbClient.isHealthy();
logger.info(
{ ensIndexerSchemaName: this.context.ensDbConfig.ensIndexerSchemaName },
"Successfully connected to ENSDb",
);
Comment thread
tk-o marked this conversation as resolved.
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`DI container initialization failed: could not connect to ENSDb due to ${errorMessage}`,
);
}
Comment thread
tk-o marked this conversation as resolved.
Comment thread
tk-o marked this conversation as resolved.
Comment thread
tk-o marked this conversation as resolved.

try {
// Initialize caches
logger.info("Initializing caches");
const [indexingStatus, stackInfo, referralProgramEditionConfigSet] = await Promise.all([
this.context.indexingStatusCache.read(),
this.context.stackInfoCache.read(),
this.context.referralProgramEditionConfigSetCache.read(),
]);
logger.info(
{ indexingStatus, stackInfo, referralProgramEditionConfigSet },
"Caches initialized",
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`DI container initialization failed: cache initialization error due to ${errorMessage}`,
);
}

// Initialize the RPC client for the ENS Root Chain by making a simple call to
// verify connectivity.
try {
logger.info("Initializing RPC client for the ENS Root Chain");
await this.context.rootChainPublicClient.getBlockNumber();
logger.info(
{ rootChainId: this.context.rootChainId },
"Successfully connected to the ENS Root Chain RPC",
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`DI container initialization failed: could not connect to ENS Root Chain RPC due to ${errorMessage}`,
);
Comment thread
tk-o marked this conversation as resolved.
}
}

/**
Expand All @@ -279,6 +327,10 @@
this.context.referralProgramEditionConfigSetCache.destroy();
logger.info("Caches destroyed");

// Destroy the ENSDb client to close the connection pool to ENSDb
this.context.ensDbClient.destroy();

Check failure on line 331 in apps/ensapi/src/di.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

src/config/config.singleton.test.ts > ensdb singleton bootstrap > exits when ENSINDEXER_SCHEMA_NAME is missing

TypeError: this.context.ensDbClient.destroy is not a function ❯ EnsApiDiContainer.destroy src/di.ts:331:30 ❯ src/config/config.singleton.test.ts:78:8

Check failure on line 331 in apps/ensapi/src/di.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

src/config/config.singleton.test.ts > ensdb singleton bootstrap > exits when ENSDB_URL is missing

TypeError: this.context.ensDbClient.destroy is not a function ❯ EnsApiDiContainer.destroy src/di.ts:331:30 ❯ src/config/config.singleton.test.ts:78:8

Check failure on line 331 in apps/ensapi/src/di.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

src/config/config.singleton.test.ts > ensdb singleton bootstrap > constructs EnsDbReader from real env wiring without errors

TypeError: this.context.ensDbClient.destroy is not a function ❯ EnsApiDiContainer.destroy src/di.ts:331:30 ❯ src/config/config.singleton.test.ts:78:8
logger.info("ENSDb client destroyed");

this._context = undefined;
Comment thread
tk-o marked this conversation as resolved.
}
Comment thread
tk-o marked this conversation as resolved.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import di from "@/di";
import {
healthCheckRoute,
readinessCheckRoute,
} from "@/handlers/ensapi-probes/ensapi-probes-api.routes";
import { ensDbClient } from "@/lib/ensdb/singleton";
import { createApp } from "@/lib/hono-factory";
import logger from "@/lib/logger";

const app = createApp();

app.openapi(healthCheckRoute, async (c) => {
try {
const { ensDbClient } = di.context;
const isEnsDbHealthy = await ensDbClient.isHealthy();

if (!isEnsDbHealthy) {
Expand All @@ -25,6 +26,7 @@ app.openapi(healthCheckRoute, async (c) => {

app.openapi(readinessCheckRoute, async (c) => {
try {
const { ensDbClient } = di.context;
const isEnsDbReady = await ensDbClient.isReady();

if (!isEnsDbReady) {
Expand Down
Loading
Loading