Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
49c7deb
Setup drizzle-kit migrations
tk-o Mar 12, 2026
0c65914
Make ENSDb Writer Worker to execute database migrations
tk-o Mar 12, 2026
3a4ebe6
Update tests and mocks for EnsDbWriterWorker
tk-o Mar 12, 2026
b94f93d
Scope `drizzle-kit` as dev dependency to ENSIndexer
tk-o Mar 12, 2026
c5ef04d
docs(changeset): Introduced database migration toolkit based on `driz…
tk-o Mar 12, 2026
20af541
docs(changeset): Extended `EnsDbClient` with `EnsDbMigration` interfa…
tk-o Mar 12, 2026
b235706
docs(changeset): Introduced `EnsDbMigration` interface.
tk-o Mar 12, 2026
a6ea0b9
Update integration tests config
tk-o Mar 12, 2026
a9b1d39
Add unit tests for migration exec task
tk-o Mar 12, 2026
c07ac36
Streamline interface name as `EnsDbClientMigration`
tk-o Mar 12, 2026
1767c4e
Use ENSNode Schema for internal drizzle migration tables
tk-o Mar 12, 2026
e979489
ENSNode GraphQL API: Ergonomic Permissions (#1745)
shrugs Mar 12, 2026
9429530
ENSv2 Plugin Events (#1744)
shrugs Mar 12, 2026
1252713
Revert "Build ENSApi Config with ENSDb Cleint" in order to reduce sco…
tk-o Mar 13, 2026
069f133
Move ENSDb module contents from ENSNode SDK into ENSNode Schema packa…
tk-o Mar 13, 2026
867d643
Create Drizzle utils for future ENSDb SDK
tk-o Mar 13, 2026
0d2ee64
Create `EnsNodeDbReader` class to be shared among ENSNode apps.
tk-o Mar 13, 2026
da33e32
rename topic0 to selector in schema and API (#1760)
shrugs Mar 13, 2026
6b8c42f
Create `EnsNodeDbWriter` to be shared with ENSIndexer app (which is c…
tk-o Mar 13, 2026
b803f50
Improve naming and code docs for ENSIndexer Schema defintions
tk-o Mar 13, 2026
d08743c
Use `EnsNodeDbWriter` in ENSIndexer app
tk-o Mar 13, 2026
f276efe
fix: allow ensv2 apis to be used without ensv2 datasource (#1758)
shrugs Mar 13, 2026
ddda81c
fix(deps): resolve devalue prototype pollution vulnerability (#1765)
shrugs Mar 13, 2026
337ef4b
ENSv2 Permissions & Events Filters Integration Tests (#1761)
shrugs Mar 13, 2026
2d760ee
Create `EnsIndexerDbReader` class to be used among ENSNode apps for u…
tk-o Mar 13, 2026
6c8b6d7
Revert "Update example env file"
tk-o Mar 13, 2026
1be0947
Revert "Update config builder test"
tk-o Mar 13, 2026
7705910
Update EnsNodeDb migrations to match the updated schema
tk-o Mar 13, 2026
16e5660
Integrate `EnsNodeDbWriter` into ENSIndexer app
tk-o Mar 13, 2026
d53f0bf
Update tests and mocks following `EnsDbWriterWorker` integration
tk-o Mar 13, 2026
5297c9c
Remove unused code
tk-o Mar 13, 2026
12359f8
Integrate `EnsIndexerDbReader` into ENSApi`
tk-o Mar 13, 2026
f35edb0
Fix exports for ENSDb Schema definitions
tk-o Mar 13, 2026
79a176d
Revert "Update integration tests config"
tk-o Mar 13, 2026
b4b5b0b
Revert "Apply terraform script updates"
tk-o Mar 13, 2026
cd31c03
fix(deps): resolve undici and yauzl security vulnerabilities (#1768)
shrugs Mar 15, 2026
24f66e7
Merge remote-tracking branch 'origin/main' into feat/ensdb-migrations
tk-o Mar 16, 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
5 changes: 5 additions & 0 deletions .changeset/big-impalas-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": minor
---

Extended `EnsDbClient` with `EnsDbClientMigration` interface implementation.
Comment on lines +1 to +5
5 changes: 5 additions & 0 deletions .changeset/breezy-corners-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

ENSNode GraphQL API: `Registration.start` is now available, indicating the start timestamp of the Registration.
6 changes: 6 additions & 0 deletions .changeset/clever-chicken-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"ensapi": minor
"ensindexer": minor
---

The ENSv2 Plugin can now be safely activated for ENSv1-only namespaces (ex: 'mainnet', 'sepolia').
5 changes: 5 additions & 0 deletions .changeset/light-ducks-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

Introduces integration testing for the ENSv2 Plugin and GraphQL API against the ENSv2 devnet.
5 changes: 5 additions & 0 deletions .changeset/seven-hands-ask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

Introduced `EnsDbClientMigration` interface.
Comment on lines +1 to +5
5 changes: 5 additions & 0 deletions .changeset/shy-wolves-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

ENSNode GraphQL API: Add `where` filters to all `*.events` connections (`Domain.events`, `Resolver.events`, `Permissions.events`, `Account.events`). Supports filtering by `selector_in`, `timestamp_gte`, `timestamp_lte`, and `from` (where applicable). Also adds `Account.events` field to find events by transaction sender.
5 changes: 5 additions & 0 deletions .changeset/silly-bats-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": minor
---

Introduced database migration toolkit based on `drizzle-kit`.
5 changes: 5 additions & 0 deletions .changeset/wide-chicken-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

ENSNode GraphQL API: `Account.events` now provides the set of Events for which this Account is the sender (i.e. `Transaction.from`).
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ generated
.terraform*
terraform.tfstate*

# CLAUDE.md
# Claude
CLAUDE.md
.claude

# ENSRainbow data
apps/ensrainbow/data*
Expand Down
5 changes: 1 addition & 4 deletions apps/ensapi/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,9 @@ ENSINDEXER_URL=http://localhost:42069
# 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: Database Schema name for ENSIndexer Schema
# Required. Should match the DATABASE_SCHEMA used by the connected ENSIndexer.
DATABASE_SCHEMA=public

# 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
Expand Down
23 changes: 0 additions & 23 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,12 @@ vi.mock("@/lib/logger", () => ({
error: vi.fn(),
info: vi.fn(),
},
makeLogger: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
trace: vi.fn(),
})),
}));

const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";

const BASE_ENV = {
DATABASE_URL: "postgresql://user:password@localhost:5432/mydb",
DATABASE_SCHEMA: "public",
ENSINDEXER_URL: "http://localhost:42069",
RPC_URL_1: VALID_RPC_URL,
} satisfies EnsApiEnvironment;
Expand All @@ -58,20 +50,6 @@ const ENSINDEXER_PUBLIC_CONFIG = {
},
} satisfies ENSIndexerPublicConfig;

// Mock EnsDbClient - must be defined after ENSINDEXER_PUBLIC_CONFIG since vi.mock is hoisted
// We'll use a simple class mock and configure it in beforeEach
const mockGetVersion = vi.fn().mockResolvedValue("1.0.0");
const mockGetEnsIndexerPublicConfig = vi.fn().mockResolvedValue(ENSINDEXER_PUBLIC_CONFIG);
const mockGetIndexingStatusSnapshot = vi.fn().mockResolvedValue(null);

vi.mock("@/lib/ensdb-client/ensdb-client", () => ({
EnsDbClient: class MockEnsDbClient {
getVersion = mockGetVersion;
getEnsIndexerPublicConfig = mockGetEnsIndexerPublicConfig;
getIndexingStatusSnapshot = mockGetIndexingStatusSnapshot;
},
}));

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

Expand Down Expand Up @@ -138,7 +116,6 @@ describe("buildConfigFromEnvironment", () => {

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

Expand Down
33 changes: 12 additions & 21 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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";

Expand All @@ -20,7 +21,7 @@ import {
import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import type { EnsApiEnvironment } from "@/config/environment";
import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations";
import { EnsDbClient } from "@/lib/ensdb-client/ensdb-client";
import { fetchENSIndexerConfig } from "@/lib/fetch-ensindexer-config";
import logger from "@/lib/logger";

export const DatabaseUrlSchema = z.string().refine(
Expand Down Expand Up @@ -76,19 +77,6 @@ const EnsApiConfigSchema = z

export type EnsApiConfig = z.infer<typeof EnsApiConfigSchema>;

/**
* Builds an instance of {@link EnsDbClient} using environment variables.
*
* @returns instance of {@link EnsDbClient}
* @throws Error with formatted validation messages if environment parsing fails
*/
function buildEnsDbClientFromEnvironment(env: EnsApiEnvironment): EnsDbClient {
const databaseUrl = DatabaseUrlSchema.parse(env.DATABASE_URL);
const ensIndexerSchemaName = DatabaseSchemaNameSchema.parse(env.DATABASE_SCHEMA);

return new EnsDbClient(databaseUrl, ensIndexerSchemaName);
}

/**
* Builds the EnsApiConfig from an EnsApiEnvironment object, fetching the EnsIndexerPublicConfig.
*
Expand All @@ -97,13 +85,16 @@ function buildEnsDbClientFromEnvironment(env: EnsApiEnvironment): EnsDbClient {
*/
export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise<EnsApiConfig> {
try {
const ensDbClient = buildEnsDbClientFromEnvironment(env);

const ensIndexerPublicConfig = await ensDbClient.getEnsIndexerPublicConfig();

if (!ensIndexerPublicConfig) {
throw new Error("Failed to load EnsIndexerPublicConfig from ENSDb.");
}
const ensIndexerUrl = EnsIndexerUrlSchema.parse(env.ENSINDEXER_URL);

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.`,
);
},
});
Comment on lines 86 to +97
Copy link
Copy Markdown
Contributor

@vercel vercel bot Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale changeset file documents a reverted architectural change, creating misleading release notes

Fix on Vercel


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

Expand Down
2 changes: 1 addition & 1 deletion apps/ensapi/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
* their state in `process.env`. This interface is intended to be the source type which then gets
* mapped/parsed into a structured configuration object like `EnsApiConfig`.
*/
export type EnsApiEnvironment = DatabaseEnvironment &
export type EnsApiEnvironment = Omit<DatabaseEnvironment, "DATABASE_SCHEMA"> &
EnsIndexerUrlEnvironment &
RpcEnvironment &
PortEnvironment &
Expand Down
13 changes: 12 additions & 1 deletion apps/ensapi/src/graphql-api/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ import type {
DomainId,
InterpretedName,
Node,
PermissionsId,
PermissionsResourceId,
PermissionsUserId,
RegistrationId,
RegistryId,
RenewalId,
ResolverId,
ResolverRecordsId,
} from "@ensnode/ensnode-sdk";

import type { context } from "@/graphql-api/context";
Expand All @@ -28,7 +34,12 @@ export const builder = new SchemaBuilder<{
DomainId: { Input: DomainId; Output: DomainId };
RegistryId: { Input: RegistryId; Output: RegistryId };
ResolverId: { Input: ResolverId; Output: ResolverId };
// PermissionsId: { Input: PermissionsId; Output: PermissionsId };
PermissionsId: { Input: PermissionsId; Output: PermissionsId };
PermissionsResourceId: { Input: PermissionsResourceId; Output: PermissionsResourceId };
PermissionsUserId: { Input: PermissionsUserId; Output: PermissionsUserId };
RegistrationId: { Input: RegistrationId; Output: RegistrationId };
RenewalId: { Input: RenewalId; Output: RenewalId };
ResolverRecordsId: { Input: ResolverRecordsId; Output: ResolverRecordsId };
};

// the following ensures via typechecker that every t.connection returns a totalCount field
Expand Down
2 changes: 1 addition & 1 deletion apps/ensapi/src/graphql-api/lib/connection-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { and, asc, desc, gt, lt } from "drizzle-orm";
import z from "zod/v4";

import { cursors } from "@/graphql-api/schema/cursors";
import { cursors } from "@/graphql-api/lib/cursors";

type Column = Parameters<typeof lt>[0];

Expand Down
20 changes: 20 additions & 0 deletions apps/ensapi/src/graphql-api/lib/cursors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import superjson from "superjson";

/**
* It's considered good practice to provide cursors as opaque strings exclusively useful for
* paginating sets, so we encode/decode values using base64'd superjson.
*
* superjson handles BigInt and other non-JSON-native types transparently.
*/
export const cursors = {
encode: <T>(value: T) => Buffer.from(superjson.stringify(value), "utf8").toString("base64"),
decode: <T>(cursor: string): T => {
try {
return superjson.parse<T>(Buffer.from(cursor, "base64").toString("utf8"));
} catch {
throw new Error(
"Invalid cursor: failed to decode cursor. The cursor may be malformed or from an incompatible query.",
);
}
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import config from "@/config";
import { sql } from "drizzle-orm";

import * as schema from "@ensnode/ensnode-schema";
import { getENSv2RootRegistryId } from "@ensnode/ensnode-sdk";
import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk";

import { db } from "@/lib/db";

Expand All @@ -22,33 +22,45 @@ import { db } from "@/lib/db";
*/
const CANONICAL_REGISTRIES_MAX_DEPTH = 16;

const ENSV2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace);

/**
* Builds a recursive CTE that traverses from the ENSv2 Root Registry to construct a set of all
* Canonical Registries. A Canonical Registry is an ENSv2 Registry that is the Root Registry or the
* (sub)Registry of a Domain in a Canonical Registry.
*
* TODO: could this be optimized further, perhaps as a materialized view?
*/
export const getCanonicalRegistriesCTE = () =>
db
export const getCanonicalRegistriesCTE = () => {
// if ENSv2 is not defined, return an empty set with identical structure to below
if (!ENSV2_ROOT_REGISTRY_ID) {
return db
.select({ id: sql<string>`registry_id`.as("id") })
.from(sql`(SELECT NULL::text AS registry_id WHERE FALSE) AS canonical_registries_cte`)
.as("canonical_registries");
}

return db
.select({
// NOTE: using `id` here to avoid clobbering `registryId` in consuming queries, which would
// result in '_ is ambiguous' error messages from postgres because drizzle isn't scoping the
// selection properly. a bit fragile but works for now.
id: sql<string>`registry_id`.as("id"),
})
.from(
sql`(
WITH RECURSIVE canonical_registries AS (
SELECT ${getENSv2RootRegistryId(config.namespace)}::text AS registry_id, 0 AS depth
UNION ALL
SELECT rcd.registry_id, cr.depth + 1
FROM ${schema.registryCanonicalDomain} rcd
JOIN ${schema.v2Domain} parent ON parent.id = rcd.domain_id AND parent.subregistry_id = rcd.registry_id
JOIN canonical_registries cr ON cr.registry_id = parent.registry_id
WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH}
)
SELECT registry_id FROM canonical_registries
) AS canonical_registries_cte`,
sql`
(
WITH RECURSIVE canonical_registries AS (
SELECT ${ENSV2_ROOT_REGISTRY_ID}::text AS registry_id, 0 AS depth
UNION ALL
SELECT rcd.registry_id, cr.depth + 1
FROM ${schema.registryCanonicalDomain} rcd
JOIN ${schema.v2Domain} parent ON parent.id = rcd.domain_id AND parent.subregistry_id = rcd.registry_id
JOIN canonical_registries cr ON cr.registry_id = parent.registry_id
WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH}
)
SELECT registry_id FROM canonical_registries
) AS canonical_registries_cte`,
)
.as("canonical_registries");
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";

import type { DomainId } from "@ensnode/ensnode-sdk";

import { DomainCursor } from "./domain-cursor";
import { type DomainCursor, DomainCursors } from "./domain-cursor";

describe("DomainCursor", () => {
describe("roundtrip encode/decode", () => {
Expand All @@ -13,7 +13,7 @@ describe("DomainCursor", () => {
dir: "ASC",
value: "example",
};
expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor);
expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor);
});

it("roundtrips with a bigint value (REGISTRATION_TIMESTAMP ordering)", () => {
Expand All @@ -23,7 +23,7 @@ describe("DomainCursor", () => {
dir: "DESC",
value: 1234567890n,
};
expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor);
expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor);
});

it("roundtrips with a bigint value (REGISTRATION_EXPIRY ordering)", () => {
Expand All @@ -33,7 +33,7 @@ describe("DomainCursor", () => {
dir: "ASC",
value: 9999999999n,
};
expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor);
expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor);
});

it("roundtrips with a null value", () => {
Expand All @@ -43,22 +43,22 @@ describe("DomainCursor", () => {
dir: "ASC",
value: null,
};
expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor);
expect(DomainCursors.decode(DomainCursors.encode(cursor))).toEqual(cursor);
});
});

describe("decode error handling", () => {
it("throws on garbage input", () => {
expect(() => DomainCursor.decode("not-valid-base64!!!")).toThrow("Invalid cursor");
expect(() => DomainCursors.decode("not-valid-base64!!!")).toThrow("Invalid cursor");
});

it("throws on valid base64 but invalid json", () => {
const notJson = Buffer.from("not json", "utf8").toString("base64");
expect(() => DomainCursor.decode(notJson)).toThrow("Invalid cursor");
expect(() => DomainCursors.decode(notJson)).toThrow("Invalid cursor");
});

it("throws on empty string", () => {
expect(() => DomainCursor.decode("")).toThrow("Invalid cursor");
expect(() => DomainCursors.decode("")).toThrow("Invalid cursor");
});
});
});
Loading
Loading