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
21 changes: 21 additions & 0 deletions .changeset/refactor-find-domains-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"ensapi": patch
---

add `Account.domains` and enhance `Domain.subdomains` and `Registry.domains` with filtering and ordering

**`Account.domains`** (new) — paginated connection of domains owned by this account.
- `where: { name?: String, canonical?: Boolean }` — optional partial Interpreted Name filter and canonical filter (defaults to false)
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering

**`Domain.subdomains`** (enhanced) — paginated connection of subdomains of this domain, now with filtering and ordering.
- `where: { name?: String }` — optional partial Interpreted Name filter
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering

**`Registry.domains`** (enhanced) — paginated connection of domains in this registry, now with filtering and ordering.
- `where: { name?: String }` — optional partial Interpreted Name filter
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering

**`Query.domains`** (updated) — `where.name` is now required. Added optional `where.canonical` filter (defaults to false).
- `where: { name: String!, canonical?: Boolean }` — required partial Interpreted Name, optional canonical filter
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ const CANONICAL_REGISTRIES_MAX_DEPTH = 16;
*/
export const getCanonicalRegistriesCTE = () =>
db
.select({ registryId: sql<string>`registry_id`.as("registryId") })
.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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ vi.mock("@/config", () => ({ default: { namespace: "mainnet" } }));
vi.mock("@/lib/db", () => ({ db: {} }));
vi.mock("@/graphql-api/lib/find-domains/find-domains-by-labelhash-path", () => ({}));

import { isEffectiveDesc } from "./find-domains";
import { isEffectiveDesc } from "./find-domains-resolver-helpers";

describe("isEffectiveDesc", () => {
it("ASC + not inverted = not desc", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { asc, desc, type SQL, sql } from "drizzle-orm";

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

/**
* Get the order column for a given DomainsOrderBy value.
*/
function getOrderColumn(
domains: DomainsWithOrderingMetadata,
orderBy: typeof DomainsOrderBy.$inferType,
) {
return {
NAME: domains.sortableLabel,
REGISTRATION_TIMESTAMP: domains.registrationTimestamp,
REGISTRATION_EXPIRY: domains.registrationExpiry,
}[orderBy];
}

/**
* Build a cursor filter for keyset pagination on findDomains results.
*
* 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
* @param effectiveDesc - Whether the effective sort direction is descending
* @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,
direction: "after" | "before",
effectiveDesc: boolean,
): SQL {
// Validate cursor was created with the same ordering as the current query
if (cursor.by !== queryOrderBy) {
throw new Error(
`Invalid cursor: cursor was created with orderBy=${cursor.by} but query uses orderBy=${queryOrderBy}`,
);
}

if (cursor.dir !== queryOrderDir) {
throw new Error(
`Invalid cursor: cursor was created with orderDir=${cursor.dir} but query uses orderDir=${queryOrderDir}`,
);
}

const orderColumn = getOrderColumn(domains, 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
const useGreaterThan = (direction === "after") !== effectiveDesc;

// Handle NULL cursor values explicitly (PostgreSQL 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}))`;
}
}

// 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 ? ">" : "<";
const value =
cursor.by === "NAME" ? sql`${cursor.value}::text` : sql`${cursor.value}::numeric(78,0)`;
return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`;
}

/**
* Compute the effective sort direction, combining user's orderDir with relay's inverted flag.
* XOR logic: inverted flips the sort for backward pagination.
*/
export function isEffectiveDesc(
orderDir: typeof OrderDirection.$inferType,
inverted: boolean,
): boolean {
return (orderDir === "DESC") !== inverted;
}

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);

// Always use NULLS LAST so unregistered domains (NULL registration fields)
// appear at the end regardless of sort direction
const primaryOrder = effectiveDesc
? sql`${orderColumn} DESC NULLS LAST`
: sql`${orderColumn} ASC NULLS LAST`;

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

return [primaryOrder, tiebreaker];
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,56 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@poth
import { and } from "drizzle-orm";

import type { context as createContext } from "@/graphql-api/context";
import type {
DomainsWithOrderingMetadata,
DomainsWithOrderingMetadataResult,
} from "@/graphql-api/lib/find-domains/layers/with-ordering-metadata";
import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors";
import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants";
import {
DOMAINS_DEFAULT_ORDER_BY,
DOMAINS_DEFAULT_ORDER_DIR,
type Domain,
DomainInterfaceRef,
type DomainsOrderBy,
} from "@/graphql-api/schema/domain";
import type { OrderDirection } from "@/graphql-api/schema/order-direction";
import { db } from "@/lib/db";
import { makeLogger } from "@/lib/logger";

import { DomainCursor } from "./domain-cursor";
import { cursorFilter, findDomains, isEffectiveDesc, orderFindDomains } from "./find-domains";
import type {
DomainOrderValue,
DomainWithOrderValue,
FindDomainsOrderArg,
FindDomainsResult,
FindDomainsWhereArg,
} from "./types";
import { cursorFilter, isEffectiveDesc, orderFindDomains } from "./find-domains-resolver-helpers";
import type { DomainOrderValue } from "./types";

/**
* Describes the ordering of the set of Domains.
*
* @dev derived from the GraphQL Input Types for 1:1 convenience
*/
interface FindDomainsOrderArg {
by?: typeof DomainsOrderBy.$inferType | null;
dir?: typeof OrderDirection.$inferType | null;
}

/**
* Domain with order value injected.
*
* @dev Relevant to composite DomainCursor encoding, see `domain-cursor.ts`
*/
type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue };

const logger = makeLogger("find-domains-resolver");

/**
* Extract the order value from a findDomains result row based on the orderBy field.
*/
function getOrderValueFromResult(
result: FindDomainsResult,
result: DomainsWithOrderingMetadataResult,
orderBy: typeof DomainsOrderBy.$inferType,
): DomainOrderValue {
switch (orderBy) {
case "NAME":
return result.headLabel;
return result.sortableLabel;
case "REGISTRATION_TIMESTAMP":
return result.registrationTimestamp;
case "REGISTRATION_EXPIRY":
Expand All @@ -43,24 +60,28 @@ function getOrderValueFromResult(
}

/**
* GraphQL API resolver for domains connection queries, used by Query.domains.
* GraphQL API resolver for domain connection queries. Accepts a pre-built domains CTE
* ({@link DomainsWithOrderingMetadata}) and handles cursor-based pagination, ordering, and
* dataloader loading.
*
* Used by Query.domains, Account.domains, Registry.domains, and Domain.subdomains.
*
* @param context - The GraphQL Context, required for Dataloader access
* @param args - The GraphQL Args object (via t.connection) + FindDomains-specific args (where, order)
* @param args - The domains CTE, optional ordering, and relay connection args
*/
export function resolveFindDomains(
context: ReturnType<typeof createContext>,
{
where,
domains,
order,
...connectionArgs
}: {
// `where` MUST be provided, we don't currently allow iterating over the full set of domains
where: FindDomainsWhereArg;
// `order` MAY be provided; defaults are used otherwise
/** Pre-built domains CTE from `withOrderingMetadata` */
domains: DomainsWithOrderingMetadata;
/** Optional ordering; defaults to NAME ASC */
order?: FindDomainsOrderArg | undefined | null;

// these resolver arguments are from t.connection
// relay connection args from t.connection
first?: number | null;
last?: number | null;
before?: string | null;
Expand All @@ -86,9 +107,6 @@ export function resolveFindDomains(
// identify whether the effective sort direction is descending
const effectiveDesc = isEffectiveDesc(orderDir, inverted);

// construct query for relevant domains
const domains = findDomains(where);

// build order clauses
const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted);

Expand Down
Loading