Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/account-domains-canonical-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": patch
---

**Omnigraph**: `AccountDomainsWhereInput.canonical` now filters on both `true` and `false` (previously `false` was a no-op). The `defaultValue: false` is dropped — clients omitting `canonical` will receive all Domains owned by the Account regardless of canonicality. Pass `canonical: true` for canonical-only or `canonical: false` for non-canonical-only. The underlying `DomainsWhere.canonical` in `resolveFindDomains` was generalized so `typeof === "boolean"` triggers the filter; `null`/`undefined` is "no filter".
9 changes: 9 additions & 0 deletions .changeset/find-domains-perf-indexes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@ensnode/ensdb-sdk": patch
---

Add three btree indexes to the indexer schema to fix slow `Domain.subdomains`, `get-domain-by-interpreted-name`, and `Query.domains` paths:

- `domain_resolver_relations(domain_id)` — secondary lookup off the PK so the namegraph-walk CTE can left-join by `domain_id` alone.
- `domains(registry_id, label_hash)` — composite (replaces the standalone `registry_id` index, which it subsumes via leading-column prefix).
- `domains(registry_id, left(canonical_name, 256), id)` — expression composite for registry-scoped `WHERE registry_id = X ORDER BY canonical_name LIMIT N` (the `Domain.subdomains` shape). The 256-char prefix bounds the index tuple under btree's per-tuple max; NAME-ordered queries must sort by the same `left(...)` expression for the planner to use this index for ordered scan.
5 changes: 5 additions & 0 deletions .changeset/sharp-towns-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": patch
---

Basenames and Lineanames are now correctly canonicalized in the `unigraph` plugin.
17 changes: 17 additions & 0 deletions apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { and, asc, desc, gt, lt } from "drizzle-orm";
import z from "zod/v4";

import { cursors } from "@/omnigraph-api/lib/cursors";
import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection";

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

Expand Down Expand Up @@ -40,3 +41,19 @@ export const paginateByInt = (
*/
export const orderPaginationBy = (column: Column, inverted: boolean) =>
inverted ? desc(column) : asc(column);

/**
* An empty Relay Connection, used when short-circuiting connection resolvers.
*/
export const EMPTY_CONNECTION = lazyConnection({
totalCount: async () => 0,
connection: async () => ({
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
}),
});
Comment thread
shrugs marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,23 +1,66 @@
import { asc, desc, type SQL, sql } from "drizzle-orm";

import { ensIndexerSchema } from "@/lib/ensdb/singleton";
import type { DomainCursor } from "@/omnigraph-api/lib/find-domains/domain-cursor";
import type { DomainsWithOrderingMetadata } from "@/omnigraph-api/lib/find-domains/layers/with-ordering-metadata";
import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs";
import type { OrderDirection } from "@/omnigraph-api/schema/order-direction";

/**
* Get the order column for a given DomainsOrderBy value.
* Length cap (in characters) of the `canonical_name` prefix used by:
* 1. the `(registry_id, left(canonical_name, N), id)` composite btree on `domains`,
* 2. all NAME-ordered queries' ORDER BY expressions, and
* 3. the value stored in `DomainCursor.value` when ordering by NAME — pre-truncated at
* encode time via {@link truncateNameForCursor} so filter-time comparisons are simple
* tuple compares against the index expression with no per-row `left(...)` re-application.
*
* The btree per-tuple max is ~2712 bytes; with `registry_id` and `id` consuming ~240 bytes of
* that, ~2400 bytes remain for the prefix expression. 256 chars × max 4-byte UTF-8 codepoint =
* 1024 bytes, well under the limit and within the realm of reasonable name lengths (mainnet avg
* is ~126). Queries MUST sort by this same expression for the planner to use the index for
* ordered scan; raw `canonical_name` ORDER BY falls back to a full scan + sort.
*
* An alternative solution is to redefine InterpretedLabel to enforce a maximum byte length of 255 before
* being truncated into an Encoded LabelHash — this mirrors a name's resolvability (must be dns-encodable)
* and allows us to avoid storing spam names. Then we'd also have to produce a b-tree-indexed
* materializedCanonicalName field that's length-capped as well to fit the btree index. Then we could
* query against that column instead of the full InterpretedName. All of that would avoid this
* LEFT(...) expression index and the necessity for the query pattern to match the defined index
* (to avoid the full scan).
*/
function getOrderColumn(
domains: DomainsWithOrderingMetadata,
orderBy: typeof DomainsOrderBy.$inferType,
) {
return {
NAME: domains.canonicalName,
DEPTH: domains.canonicalDepth,
REGISTRATION_TIMESTAMP: domains.registrationTimestamp,
REGISTRATION_EXPIRY: domains.registrationExpiry,
}[orderBy];
export const CANONICAL_NAME_SORT_PREFIX = 256;

/**
* Truncate a `canonicalName` to the cursor / index prefix length. Used when writing the cursor
* value for NAME orderings — callers slice once at encode time so the encoded cursor stays small
* (long names can hit thousands of characters) and `cursorFilter` can compare directly against
* the index expression without re-applying `left(...)` per row.
*
* Uses code-point iteration (`[...name]`) rather than `String.slice`, which counts UTF-16 code
* units and would split surrogate pairs. Postgres `left(text, N)` counts characters (code
* points), so this keeps the JS-side and DB-side prefixes byte-identical.
*/
export function truncateNameForCursor(name: string | null): string | null {
return name === null ? null : [...name].slice(0, CANONICAL_NAME_SORT_PREFIX).join("");
}

/**
* The order column / expression for each `DomainsOrderBy` value.
*
* Computed lazily using sql template so importing this module doesn't access the lazyProxy-backed
* `ensIndexerSchema` at module load time (test harnesses import it without env-driven DB
* config wired up).
*/
function getOrderColumn(orderBy: typeof DomainsOrderBy.$inferType): SQL {
switch (orderBy) {
case "NAME":
return sql`left(${ensIndexerSchema.domain.canonicalName}, ${sql.raw(String(CANONICAL_NAME_SORT_PREFIX))})`;
case "DEPTH":
return sql`${ensIndexerSchema.domain.canonicalDepth}`;
case "REGISTRATION_TIMESTAMP":
return sql`${ensIndexerSchema.registration.start}`;
case "REGISTRATION_EXPIRY":
return sql`${ensIndexerSchema.registration.expiry}`;
}
}

/**
Expand All @@ -26,17 +69,14 @@ function getOrderColumn(
* Uses tuple comparison for non-NULL cursor values, and explicit NULL handling
* for NULL cursor values (since PostgreSQL tuple comparison with NULL yields NULL/unknown).
*
* @param domains - The domains CTE
* @param cursor - The decoded DomainCursor
* @param queryOrderBy - The order field for the current query (must match cursor.by)
* @param queryOrderDir - The order direction for the current query (must match cursor.dir)
* @param direction - "after" for forward pagination, "before" for backward
* @throws if cursor.by does not match queryOrderBy
* @throws if cursor.dir does not match queryOrderDir
* @returns SQL expression for the cursor filter
*/
export function cursorFilter(
domains: DomainsWithOrderingMetadata,
cursor: DomainCursor,
queryOrderBy: typeof DomainsOrderBy.$inferType,
queryOrderDir: typeof OrderDirection.$inferType,
Expand All @@ -55,38 +95,28 @@ export function cursorFilter(
);
}

const orderColumn = getOrderColumn(domains, cursor.by);
const orderColumn = getOrderColumn(cursor.by);

// Determine comparison direction:
// - "after" with ASC = greater than cursor
// - "after" with DESC = less than cursor
// - "before" with ASC = less than cursor
// - "before" with DESC = greater than cursor
// "after" with ASC and "before" with DESC both step forward in cursor order (greater-than).
const useGreaterThan = (direction === "after") !== (queryOrderDir === "DESC");
const op = sql.raw(useGreaterThan ? ">" : "<");
const idCmp = sql`${ensIndexerSchema.domain.id} ${op} ${cursor.id}`;

// Handle NULL cursor values explicitly (PostgreSQL tuple comparison with NULL yields NULL/unknown)
// With NULLS LAST ordering: non-NULL values come before NULL values
// NULL cursor values need explicit handling because Postgres tuple comparison with NULL yields
// NULL/unknown. With NULLS LAST ordering, non-NULL values come before NULL values.
if (cursor.value === null) {
if (direction === "after") {
// "after" a NULL = other NULLs with appropriate id comparison
return useGreaterThan
? sql`(${orderColumn} IS NULL AND ${domains.id} > ${cursor.id})`
: sql`(${orderColumn} IS NULL AND ${domains.id} < ${cursor.id})`;
} else {
// "before" a NULL = all non-NULLs (they come before NULLs) + NULLs with appropriate id
return useGreaterThan
? sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${domains.id} > ${cursor.id}))`
: sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${domains.id} < ${cursor.id}))`;
}
return direction === "after"
? sql`(${orderColumn} IS NULL AND ${idCmp})`
: sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${idCmp}))`;
}

// Non-null cursor: use tuple comparison
// NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL
// NOTE: explicit cast required — Postgres can't infer parameter types in tuple comparisons
const op = useGreaterThan ? ">" : "<";
// Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL.
// Explicit cast required — Postgres can't infer parameter types in tuple comparisons.
const value = (() => {
switch (cursor.by) {
case "NAME":
// Already pre-truncated at encode time (see `truncateNameForCursor`), so this matches
// the index expression `left(canonical_name, CANONICAL_NAME_SORT_PREFIX)` directly.
return sql`${cursor.value}::text`;
case "DEPTH":
return sql`${cursor.value}::int`;
Expand All @@ -95,7 +125,8 @@ export function cursorFilter(
return sql`${cursor.value}::numeric(78,0)`;
}
})();
return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`;

return sql`(${orderColumn}, ${ensIndexerSchema.domain.id}) ${op} (${value}, ${cursor.id})`;
}

/**
Expand All @@ -110,13 +141,12 @@ export function isEffectiveDesc(
}

export function orderFindDomains(
domains: DomainsWithOrderingMetadata,
orderBy: typeof DomainsOrderBy.$inferType,
orderDir: typeof OrderDirection.$inferType,
inverted: boolean,
): SQL[] {
const effectiveDesc = isEffectiveDesc(orderDir, inverted);
const orderColumn = getOrderColumn(domains, orderBy);
const orderColumn = getOrderColumn(orderBy);

// Always use NULLS LAST so unregistered domains (NULL registration fields)
// appear at the end regardless of sort direction
Expand All @@ -125,7 +155,9 @@ export function orderFindDomains(
: sql`${orderColumn} ASC NULLS LAST`;

// Always include id as tiebreaker for stable ordering
const tiebreaker = effectiveDesc ? desc(domains.id) : asc(domains.id);
const tiebreaker = effectiveDesc
? desc(ensIndexerSchema.domain.id)
: asc(ensIndexerSchema.domain.id);

return [primaryOrder, tiebreaker];
}
Loading
Loading