Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
45b3ed9
Introduce `DATABASE_SCHEMA` to ENSApi env settings
tk-o Mar 21, 2026
255b969
Create ENSDb Config file
tk-o Mar 21, 2026
2dc7893
Replace fetching data from ENSIndexer Client with ENSDb Client
tk-o Mar 21, 2026
d6eb62d
Remove ENSIndexer dependencies from ENSApi
tk-o Mar 21, 2026
5064db7
Remove `ENSINDEXER_URL` env var from ENSApi
tk-o Mar 21, 2026
281f4fb
Update testing suite
tk-o Mar 21, 2026
35f7d22
Merge remote-tracking branch 'origin/main' into feat/integrate-ensdb-…
tk-o Mar 22, 2026
6265901
Apply AI PR feedback
tk-o Mar 23, 2026
c7738ec
Update `client` getter to `drizzle` for `EnsDbReader` and `EnsDbWriter`
tk-o Mar 23, 2026
22cc91b
Replace drizzle client and ENSIndexer Schema references in ENSApi
tk-o Mar 23, 2026
3aa85fc
Replace `DATABASE_SCHEMA` env var with `ENSINDEXER_SCHEMA_NAME` for E…
tk-o Mar 23, 2026
ee4456c
Update Integration Test Env
tk-o Mar 23, 2026
670f55f
Merge remote-tracking branch 'origin/main' into feat/integrate-ensdb-…
tk-o Mar 23, 2026
43d1adb
Adjust the amount of retires for ENSApi loading ENSIndexer Public Con…
tk-o Mar 23, 2026
b8590db
Apply AI PR feedback
tk-o Mar 23, 2026
d70d4b3
Update OpenAPI spec file
tk-o Mar 23, 2026
9718ada
docs(changeset): Renamed the `client` getter on `EnsDbReader` class t…
tk-o Mar 23, 2026
5c84b5a
docs(changeset): Updated data source for `EnsIndexerPublicConfig`, fr…
tk-o Mar 23, 2026
bbbde56
docs(changeset): Updated data source for Indexing Status snapshot, fr…
tk-o Mar 23, 2026
13eba04
docs(changeset): Updated custom queries for ENSDb to implement data m…
tk-o Mar 23, 2026
3ba2be9
Apply AI PR feedback
tk-o Mar 23, 2026
d621877
Merge remote-tracking branch 'origin/main' into feat/integrate-ensdb-…
tk-o Mar 23, 2026
e6b2239
Apply suggestions from code review
tk-o Mar 24, 2026
2ccb3dd
Merge remote-tracking branch 'origin/main' into feat/integrate-ensdb-…
tk-o Mar 24, 2026
63cf68a
Rename the `drizzle` getter on `EnsDbReader` class to `ensDb`.
tk-o Mar 24, 2026
32943fc
Reanme `ensDbConnectionString` to `ensDbUrl`
tk-o Mar 24, 2026
a8b7f86
Reanme `ensDbConnectionString` to `ensDbUrl`
tk-o Mar 24, 2026
ae8cd89
Tweak the indexing status cache settings for ENSApi
tk-o Mar 24, 2026
cefbdbb
Applying PR feedback
tk-o Mar 24, 2026
c0f97ef
Merge remote-tracking branch 'origin/main' into feat/integrate-ensdb-…
tk-o Mar 24, 2026
383477b
Fix integration tests
tk-o Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions apps/ensapi/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,27 @@
# Optional. If this is not set, the default value is 4334.
# PORT=4334

# ENSIndexer: The "primary" ENSIndexer service URL.
# Required. This URL is used to read ENSIndexer's Config and Indexing Status APIs.
ENSINDEXER_URL=http://localhost:42069

# ENSDb: Database URL
# Required. This is the connection string for the ENSDb database in which ENSIndexer is storing data.
# It should match the DATABASE_URL used by the connected ENSIndexer.
Comment thread
tk-o marked this conversation as resolved.
Outdated
# It should be in the format of `postgresql://<username>:<password>@<host>:<port>/<database>`
#
# See https://ensnode.io/ensindexer/usage/configuration/ for additional information.
# NOTE that ENSApi does NOT need to define DATABASE_SCHEMA, as it is inferred from the connected ENSIndexer's Config.
DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database

# ENSDb: ENSIndexer Schema Name
# Required. This is a name of the database schema where the connected ENSIndexer stores indexed data.
Comment thread
tk-o marked this conversation as resolved.
Outdated
# It should match the DATABASE_SCHEMA used by the connected ENSIndexer.
Comment thread
tk-o marked this conversation as resolved.
Outdated
# ENSApi instance needs this value to connect with ENSDb.
Comment thread
tk-o marked this conversation as resolved.
Outdated
ENSINDEXER_SCHEMA_NAME=ensindexer_0

# ENSApi: RPC Configuration
# Required. ENSApi requires an HTTP RPC to the connected ENSIndexer's ENS Root Chain, which depends
# on ENSIndexer's NAMESPACE (ex: mainnet, sepolia, ens-test-env). This ENS Root Chain RPC
# is used to power the Resolution API, in situations where Protocol Acceleration is not possible.
Comment thread
tk-o marked this conversation as resolved.
Outdated
#
# When ENSApi starts up it connects to the indicated ENSINDEXER_URL verifies that the ENS Root Chain
# RPC for the specified namespace is defined.
# When ENSApi starts up it loads ENSIndexer public config from ENSDb and verifies that
Comment thread
tk-o marked this conversation as resolved.
Outdated
# the ENS Root Chain RPC for the namespace is defined.
Comment thread
tk-o marked this conversation as resolved.
Outdated
#
# NOTE: You must configure your own private RPC endpoints. Public RPC endpoints are rate limited and
# will likely not provide acceptable performance (though this depends on how many non-acceleratable
Expand Down
29 changes: 12 additions & 17 deletions apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import config from "@/config";

import {
type CrossChainIndexingStatusSnapshot,
ENSNodeClient,
IndexingStatusResponseCodes,
SWRCache,
} from "@ensnode/ensnode-sdk";
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";

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

const logger = makeLogger("indexing-status.cache");
const client = new ENSNodeClient({ url: config.ensIndexerUrl });

export const indexingStatusCache = new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async (_cachedResult) =>
client
.indexingStatus() // fetch a new indexing status snapshot
.then((response) => {
if (response.responseCode !== IndexingStatusResponseCodes.Ok) {
// An indexing status response was successfully fetched, but the response code contained within the response was not 'ok'.
ensDbClient
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
.then((snapshot) => {
if (snapshot === undefined) {
// An indexing status snapshot has not been found in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the first snapshot.
// Therefore, throw an error to trigger the subsequent `.catch` handler.
throw new Error("Received Indexing Status response with responseCode other than 'ok'.");
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
}

logger.info("Fetched Indexing Status to be cached");
Comment thread
tk-o marked this conversation as resolved.
Outdated
Expand All @@ -29,10 +24,10 @@ export const indexingStatusCache = new SWRCache<CrossChainIndexingStatusSnapshot
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return response.realtimeProjection.snapshot;
return snapshot;
})
.catch((error) => {
// Either the indexing status snapshot fetch failed, or the indexing status response was not 'ok'.
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
Comment thread
tk-o marked this conversation as resolved.
Expand Down
58 changes: 11 additions & 47 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import packageJson from "@/../package.json" with { type: "json" };

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
type ENSIndexerPublicConfig,
PluginName,
serializeENSIndexerPublicConfig,
} from "@ensnode/ensnode-sdk";
import { type ENSIndexerPublicConfig, PluginName } from "@ensnode/ensnode-sdk";
import type { RpcConfig } from "@ensnode/ensnode-sdk/internal";

vi.mock("@/lib/ensdb/singleton", () => ({
ensDbClient: {
getEnsIndexerPublicConfig: vi.fn(async () => ENSINDEXER_PUBLIC_CONFIG),
},
}));
Comment thread
tk-o marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
tk-o marked this conversation as resolved.

import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema";
import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import type { EnsApiEnvironment } from "@/config/environment";
Expand All @@ -25,7 +27,6 @@ const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";

const BASE_ENV = {
DATABASE_URL: "postgresql://user:password@localhost:5432/mydb",
ENSINDEXER_URL: "http://localhost:42069",
RPC_URL_1: VALID_RPC_URL,
} satisfies EnsApiEnvironment;

Expand All @@ -50,29 +51,16 @@ const ENSINDEXER_PUBLIC_CONFIG = {
},
} satisfies ENSIndexerPublicConfig;

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

describe("buildConfigFromEnvironment", () => {
afterEach(() => {
mockFetch.mockReset();
});

it("returns a valid config object using environment variables", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

await expect(buildConfigFromEnvironment(BASE_ENV)).resolves.toStrictEqual({
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
theGraphApiKey: undefined,

ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
rpcConfigs: new Map([
[
1,
Expand All @@ -89,11 +77,6 @@ describe("buildConfigFromEnvironment", () => {
it("parses CUSTOM_REFERRAL_PROGRAM_EDITIONS as a URL object", async () => {
const customUrl = "https://example.com/editions.json";

mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

const config = await buildConfigFromEnvironment({
...BASE_ENV,
CUSTOM_REFERRAL_PROGRAM_EDITIONS: customUrl,
Expand All @@ -116,15 +99,9 @@ describe("buildConfigFromEnvironment", () => {

const TEST_ENV: EnsApiEnvironment = {
DATABASE_URL: BASE_ENV.DATABASE_URL,
ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL,
};

it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

await buildConfigFromEnvironment({
...TEST_ENV,
CUSTOM_REFERRAL_PROGRAM_EDITIONS: "not-a-url",
Expand All @@ -137,11 +114,6 @@ describe("buildConfigFromEnvironment", () => {
});

it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

await buildConfigFromEnvironment({
...TEST_ENV,
QUICKNODE_API_KEY: "my-api-key",
Expand All @@ -157,11 +129,6 @@ describe("buildConfigFromEnvironment", () => {
});

it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

await buildConfigFromEnvironment({
...TEST_ENV,
QUICKNODE_ENDPOINT_NAME: "my-endpoint-name",
Expand All @@ -183,10 +150,9 @@ describe("buildEnsApiPublicConfig", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
rpcConfigs: new Map([
[
1,
Expand Down Expand Up @@ -215,10 +181,9 @@ describe("buildEnsApiPublicConfig", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
rpcConfigs: new Map(),
customReferralProgramEditionConfigSetUrl: undefined,
};
Expand All @@ -245,14 +210,13 @@ describe("buildEnsApiPublicConfig", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
ensIndexerPublicConfig: {
...ENSINDEXER_PUBLIC_CONFIG,
plugins: ["subgraph"],
isSubgraphCompatible: true,
},
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
rpcConfigs: new Map(),
customReferralProgramEditionConfigSetUrl: undefined,
theGraphApiKey: "secret-api-key",
Expand Down
56 changes: 21 additions & 35 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import packageJson from "@/../package.json" with { type: "json" };

import pRetry from "p-retry";
import { parse as parseConnectionString } from "pg-connection-string";
import { prettifyError, ZodError, z } from "zod/v4";

import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk";
import {
buildRpcConfigsFromEnv,
canFallbackToTheGraph,
DatabaseSchemaNameSchema,
ENSNamespaceSchema,
EnsIndexerUrlSchema,
invariant_rpcConfigsSpecifiedForRootChain,
makeENSIndexerPublicConfigSchema,
OptionalPortNumberSchema,
Expand All @@ -19,29 +16,12 @@ import {
} from "@ensnode/ensnode-sdk/internal";

import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import { EnsDbConfigSchema } from "@/config/ensdb-config.schema";
import type { EnsApiEnvironment } from "@/config/environment";
import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations";
import { fetchENSIndexerConfig } from "@/lib/fetch-ensindexer-config";
import { ensDbClient } from "@/lib/ensdb/singleton";
import logger from "@/lib/logger";

export const DatabaseUrlSchema = z.string().refine(
(url) => {
try {
if (!url.startsWith("postgresql://") && !url.startsWith("postgres://")) {
return false;
}
const config = parseConnectionString(url);
return !!(config.host && config.port && config.database);
} catch {
return false;
}
},
{
error:
"Invalid PostgreSQL connection string. Expected format: postgresql://username:password@host:port/database",
},
);

/**
* Schema for validating custom referral program edition config set URL.
*/
Expand All @@ -63,15 +43,13 @@ const CustomReferralProgramEditionConfigSetUrlSchema = z
const EnsApiConfigSchema = z
.object({
port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT),
databaseUrl: DatabaseUrlSchema,
databaseSchemaName: DatabaseSchemaNameSchema,
ensIndexerUrl: EnsIndexerUrlSchema,
theGraphApiKey: TheGraphApiKeySchema,
namespace: ENSNamespaceSchema,
rpcConfigs: RpcConfigsSchema,
ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"),
customReferralProgramEditionConfigSetUrl: CustomReferralProgramEditionConfigSetUrlSchema,
})
.extend(EnsDbConfigSchema.shape)
.check(invariant_rpcConfigsSpecifiedForRootChain)
.check(invariant_ensIndexerPublicConfigVersionInfo);

Expand All @@ -85,27 +63,35 @@ export type EnsApiConfig = z.infer<typeof EnsApiConfigSchema>;
*/
export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise<EnsApiConfig> {
try {
const ensIndexerUrl = EnsIndexerUrlSchema.parse(env.ENSINDEXER_URL);
const ensIndexerPublicConfig = await pRetry(
Comment thread
tk-o marked this conversation as resolved.
async () => {
const config = await ensDbClient.getEnsIndexerPublicConfig();

const ensIndexerPublicConfig = await pRetry(() => fetchENSIndexerConfig(ensIndexerUrl), {
retries: 3,
onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => {
logger.info(
`ENSIndexer Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`,
);
if (!config) {
throw new Error("ENSIndexer Public Config not yet available in ENSDb.");
}
Comment thread
tk-o marked this conversation as resolved.

return config;
},
});
{
retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy
Comment thread
tk-o marked this conversation as resolved.
onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => {
logger.info(
`ENSIndexer Public Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`,
);
},
},
);
Comment thread
tk-o marked this conversation as resolved.

const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace);

return EnsApiConfigSchema.parse({
port: env.PORT,
databaseUrl: env.DATABASE_URL,
ensIndexerUrl: env.ENSINDEXER_URL,
theGraphApiKey: env.THEGRAPH_API_KEY,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
databaseSchemaName: ensIndexerPublicConfig.databaseSchemaName,
ensIndexerSchemaName: ensIndexerPublicConfig.databaseSchemaName,
rpcConfigs,
customReferralProgramEditionConfigSetUrl: env.CUSTOM_REFERRAL_PROGRAM_EDITIONS,
});
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.
Comment thread
tk-o marked this conversation as resolved.
Expand Down
Loading
Loading