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
113 changes: 91 additions & 22 deletions apps/ensapi/src/config/config.singleton.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,100 @@
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 = {
$client: {
end: vi.fn().mockResolvedValue(undefined),
},
};
ensIndexerSchema = {};
ensIndexerSchemaName = "ensindexer_test";
async isHealthy() {
return true;
}
async destroy() {}
}

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(() => {
afterEach(async () => {
// Restore env before destroying to prevent validation errors during cleanup
vi.unstubAllEnvs();
// Destroy might fail if init failed, but we want to clean up regardless
try {
await di.destroy();
} catch {
// If destroy fails due to process.exit mock or other issues, force reset
// @ts-expect-error - accessing private member for test cleanup
di._context = undefined;
}
});

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,14 +107,14 @@ 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");

expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();

try {
await expect(di.init()).rejects.toThrow("process.exit");
expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
} finally {
mockExit.mockRestore();
}
});

it("exits when ENSINDEXER_SCHEMA_NAME is missing", async () => {
Expand All @@ -55,13 +124,13 @@ 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");

expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();

try {
await expect(di.init()).rejects.toThrow("process.exit");
expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
} finally {
mockExit.mockRestore();
}
});
});
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;
Loading
Loading