diff --git a/.vscode/settings.json b/.vscode/settings.json index 334f94e..855972d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,9 @@ }, "[json]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/apps/ensnode/docs/V2.md b/apps/ensnode/docs/V2.md index d01ebb4..b298c10 100644 --- a/apps/ensnode/docs/V2.md +++ b/apps/ensnode/docs/V2.md @@ -79,6 +79,8 @@ in the subgraph implementation, resolver handlers must upsert resolvers because resolvers should be keyed by `(chainId, address)` and manage a mapping of records for a node, to be more protocol-centric. `coinTypes` and `texts` keys & values should be fully indexed (if possible — intentionally ignored in the subgraph because of some historical reason...) +> Yes, when it comes to all forms of key -> value pairs that comprise resolver records, the ENS Subgraph only indexes the keys and not the values. The motivation for this comes from a concern that some apps might improperly decide to use the ENS Subgraph as a source of truth for resolver record values, rather than ENS protocol standards for how resolver record values should be dynamically looked up. A naive implementation that considers the ENS Subgraph as a source of truth for these can cause a lot of problems. + ### registrar the subgraph implements all of the BaseRegistrar, EthRegistrarController, and EthRegistrarControllerOld logic together diff --git a/apps/ensnode/package.json b/apps/ensnode/package.json index 74ede8e..9885720 100644 --- a/apps/ensnode/package.json +++ b/apps/ensnode/package.json @@ -23,17 +23,17 @@ }, "dependencies": { "@ensdomains/ensjs": "^4.0.2", - "ponder-schema": "workspace:*", "ensnode-utils": "workspace:*", - "ponder-subgraph-api": "workspace:*", "hono": "catalog:", "ponder": "catalog:", + "ponder-schema": "workspace:*", + "ponder-subgraph-api": "workspace:*", "ts-deepmerge": "^7.0.2", "viem": "catalog:" }, "devDependencies": { - "@namehash/shared-configs": "workspace:", "@biomejs/biome": "catalog:", + "@namehash/shared-configs": "workspace:", "@types/node": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/apps/ensnode/src/api/index.ts b/apps/ensnode/src/api/index.ts index 6101cc7..0d5f1e9 100644 --- a/apps/ensnode/src/api/index.ts +++ b/apps/ensnode/src/api/index.ts @@ -7,4 +7,44 @@ import { graphql as subgraphGraphQL } from "ponder-subgraph-api/middleware"; ponder.use("/", ponderGraphQL()); // use our custom graphql middleware at /subgraph -ponder.use("/subgraph", subgraphGraphQL({ schema })); +ponder.use( + "/subgraph", + subgraphGraphQL({ + schema, + + // describes the polymorphic (interface) relationships in the schema + polymorphicConfig: { + types: { + DomainEvent: [ + schema.transfer, + schema.newOwner, + schema.newResolver, + schema.newTTL, + schema.wrappedTransfer, + schema.nameWrapped, + schema.nameUnwrapped, + schema.fusesSet, + schema.expiryExtended, + ], + RegistrationEvent: [schema.nameRegistered, schema.nameRenewed, schema.nameTransferred], + ResolverEvent: [ + schema.addrChanged, + schema.multicoinAddrChanged, + schema.nameChanged, + schema.abiChanged, + schema.pubkeyChanged, + schema.textChanged, + schema.contenthashChanged, + schema.interfaceChanged, + schema.authorisationChanged, + schema.versionChanged, + ], + }, + fields: { + "Domain.events": "DomainEvent", + "Registration.events": "RegistrationEvent", + "Resolver.events": "ResolverEvent", + }, + }, + }), +); diff --git a/apps/ensnode/src/handlers/NameWrapper.ts b/apps/ensnode/src/handlers/NameWrapper.ts index c15dcde..b44c76e 100644 --- a/apps/ensnode/src/handlers/NameWrapper.ts +++ b/apps/ensnode/src/handlers/NameWrapper.ts @@ -1,12 +1,13 @@ -import { type Context, type Event, type EventNames } from "ponder:registry"; +import { type Context } from "ponder:registry"; import schema from "ponder:schema"; import { checkPccBurned } from "@ensdomains/ensjs/utils"; import { decodeDNSPacketBytes, tokenIdToLabel } from "ensnode-utils/subname-helpers"; import type { Node } from "ensnode-utils/types"; import { type Address, type Hex, hexToBytes, namehash } from "viem"; -import { upsertAccount } from "../lib/db-helpers"; +import { sharedEventValues, upsertAccount } from "../lib/db-helpers"; import { bigintMax } from "../lib/helpers"; import { makeEventId } from "../lib/ids"; +import { EventWithArgs } from "../lib/ponder-helpers"; import type { OwnedName } from "../lib/types"; // if the wrappedDomain has PCC set in fuses, set domain's expiryDate to the greatest of the two @@ -33,7 +34,7 @@ async function materializeDomainExpiryDate(context: Context, node: Node) { async function handleTransfer( context: Context, - event: Event, + event: EventWithArgs, eventId: string, tokenId: bigint, to: Address, @@ -64,7 +65,13 @@ async function handleTransfer( ownerId: to, }); - // TODO: log WrappedTransfer + // log DomainEvent + await context.db.insert(schema.wrappedTransfer).values({ + ...sharedEventValues(event), + id: eventId, // NOTE: override the shared id in this case, to account for TransferBatch + domainId: node, + ownerId: to, + }); } export const makeNameWrapperHandlers = (ownedName: OwnedName) => { @@ -76,15 +83,13 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: { - args: { - node: Node; - owner: Hex; - fuses: number; - expiry: bigint; - name: Hex; - }; - }; + event: EventWithArgs<{ + node: Node; + owner: Hex; + fuses: number; + expiry: bigint; + name: Hex; + }>; }) { const { node, owner, fuses, expiry } = event.args; @@ -111,7 +116,15 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { // materialize domain expiryDate await materializeDomainExpiryDate(context, node); - // TODO: log NameWrapped + // log DomainEvent + await context.db.insert(schema.nameWrapped).values({ + ...sharedEventValues(event), + domainId: node, + name, + fuses, + ownerId: owner, + expiryDate: expiry, + }); }, async handleNameUnwrapped({ @@ -119,12 +132,7 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: { - args: { - node: Node; - owner: Hex; - }; - }; + event: EventWithArgs<{ node: Node; owner: Hex }>; }) { const { node, owner } = event.args; @@ -140,7 +148,12 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { // delete the WrappedDomain await context.db.delete(schema.wrappedDomain, { id: node }); - // TODO: log NameUnwrapped + // log DomainEvent + await context.db.insert(schema.nameUnwrapped).values({ + ...sharedEventValues(event), + domainId: node, + ownerId: owner, + }); }, async handleFusesSet({ @@ -148,12 +161,7 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: { - args: { - node: Node; - fuses: number; - }; - }; + event: EventWithArgs<{ node: Node; fuses: number }>; }) { const { node, fuses } = event.args; @@ -170,19 +178,19 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { await materializeDomainExpiryDate(context, node); } - // TODO: log FusesBurned event + // log DomainEvent + await context.db.insert(schema.fusesSet).values({ + ...sharedEventValues(event), + domainId: node, + fuses, + }); }, async handleExpiryExtended({ context, event, }: { context: Context; - event: { - args: { - node: Node; - expiry: bigint; - }; - }; + event: EventWithArgs<{ node: Node; expiry: bigint }>; }) { const { node, expiry } = event.args; @@ -199,19 +207,19 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { await materializeDomainExpiryDate(context, node); } - // TODO: log ExpiryExtended + // log DomainEvent + await context.db.insert(schema.expiryExtended).values({ + ...sharedEventValues(event), + domainId: node, + expiryDate: expiry, + }); }, async handleTransferSingle({ context, event, }: { context: Context; - event: Event & { - args: { - id: bigint; - to: Hex; - }; - }; + event: EventWithArgs<{ id: bigint; to: Hex }>; }) { await handleTransfer( context, @@ -226,12 +234,7 @@ export const makeNameWrapperHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: Event & { - args: { - ids: readonly bigint[]; - to: Hex; - }; - }; + event: EventWithArgs<{ ids: readonly bigint[]; to: Hex }>; }) { for (const [i, id] of event.args.ids.entries()) { await handleTransfer( diff --git a/apps/ensnode/src/handlers/Registrar.ts b/apps/ensnode/src/handlers/Registrar.ts index f8b172f..4943fb8 100644 --- a/apps/ensnode/src/handlers/Registrar.ts +++ b/apps/ensnode/src/handlers/Registrar.ts @@ -6,10 +6,11 @@ import { tokenIdToLabel, } from "ensnode-utils/subname-helpers"; import type { Labelhash } from "ensnode-utils/types"; -import { Block } from "ponder"; + import { type Hex, labelhash, namehash } from "viem"; -import { upsertAccount, upsertRegistration } from "../lib/db-helpers"; +import { sharedEventValues, upsertAccount, upsertRegistration } from "../lib/db-helpers"; import { makeRegistrationId } from "../lib/ids"; +import { EventWithArgs } from "../lib/ponder-helpers"; import type { OwnedName } from "../lib/types"; const GRACE_PERIOD_SECONDS = 7776000n; // 90 days in seconds @@ -20,7 +21,7 @@ const GRACE_PERIOD_SECONDS = 7776000n; // 90 days in seconds export const makeRegistrarHandlers = (ownedName: OwnedName) => { const ownedSubnameNode = namehash(ownedName); - async function setNamePreimage(context: Context, name: string, label: Hex, cost: bigint) { + async function setNamePreimage(context: Context, name: string, label: Labelhash, cost: bigint) { // NOTE: ponder intentionally removes null bytes to spare users the footgun of // inserting null bytes into postgres. We don't like this behavior, though, because it's // important that 'vitalik\x00'.eth and vitalik.eth are differentiable. @@ -62,10 +63,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: { - block: Block; - args: { id: bigint; owner: Hex; expires: bigint }; - }; + event: EventWithArgs<{ id: bigint; owner: Hex; expires: bigint }>; }) { const { id, owner, expires } = event.args; @@ -92,26 +90,39 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { labelName, }); - // TODO: log Event + // log RegistrationEvent + await context.db.insert(schema.nameRegistered).values({ + ...sharedEventValues(event), + registrationId: label, + registrantId: owner, + expiryDate: expires, + }); }, async handleNameRegisteredByController({ context, - args: { name, label, cost }, + event, }: { context: Context; - args: { name: string; label: Labelhash; cost: bigint }; + event: EventWithArgs<{ + name: string; + label: Labelhash; + cost: bigint; + expires: bigint; + }>; }) { + const { name, label, cost } = event.args; await setNamePreimage(context, name, label, cost); }, async handleNameRenewedByController({ context, - args: { name, label, cost }, + event, }: { context: Context; - args: { name: string; label: Labelhash; cost: bigint }; + event: EventWithArgs<{ name: string; label: Labelhash; cost: bigint }>; }) { + const { name, label, cost } = event.args; await setNamePreimage(context, name, label, cost); }, @@ -120,9 +131,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: { - args: { id: bigint; expires: bigint }; - }; + event: EventWithArgs<{ id: bigint; expires: bigint }>; }) { const { id, expires } = event.args; @@ -139,20 +148,23 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { .update(schema.domain, { id: node }) .set({ expiryDate: expires + GRACE_PERIOD_SECONDS }); - // TODO: log Event + // log RegistrationEvent + + await context.db.insert(schema.nameRenewed).values({ + ...sharedEventValues(event), + registrationId: label, + expiryDate: expires, + }); }, async handleNameTransferred({ context, - args: { tokenId, to }, + event, }: { context: Context; - args: { - tokenId: bigint; - from: Hex; - to: Hex; - }; + event: EventWithArgs<{ tokenId: bigint; from: Hex; to: Hex }>; }) { + const { tokenId, to } = event.args; await upsertAccount(context, to); const label = tokenIdToLabel(tokenId); @@ -170,7 +182,12 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { await context.db.update(schema.domain, { id: node }).set({ registrantId: to }); - // TODO: log Event + // log RegistrationEvent + await context.db.insert(schema.nameTransferred).values({ + ...sharedEventValues(event), + registrationId: label, + newOwnerId: to, + }); }, }; }; diff --git a/apps/ensnode/src/handlers/Registry.ts b/apps/ensnode/src/handlers/Registry.ts index 2c0ac79..dcad5ae 100644 --- a/apps/ensnode/src/handlers/Registry.ts +++ b/apps/ensnode/src/handlers/Registry.ts @@ -3,10 +3,10 @@ import schema from "ponder:schema"; import { encodeLabelhash } from "@ensdomains/ensjs/utils"; import { ROOT_NODE, makeSubnodeNamehash } from "ensnode-utils/subname-helpers"; import type { Labelhash, Node } from "ensnode-utils/types"; -import { Block } from "ponder"; import { type Hex, zeroAddress } from "viem"; -import { upsertAccount, upsertResolver } from "../lib/db-helpers"; +import { sharedEventValues, upsertAccount, upsertResolver } from "../lib/db-helpers"; import { makeResolverId } from "../lib/ids"; +import { EventWithArgs } from "../lib/ponder-helpers"; /** * Initialize the ENS root node with the zeroAddress as the owner. @@ -81,10 +81,7 @@ export async function handleTransfer({ event, }: { context: Context; - event: { - args: { node: Hex; owner: Hex }; - block: Block; - }; + event: EventWithArgs<{ node: Hex; owner: Hex }>; }) { const { node, owner } = event.args; @@ -103,7 +100,12 @@ export async function handleTransfer({ await recursivelyRemoveEmptyDomainFromParentSubdomainCount(context, node); } - // TODO: log DomainEvent + // log DomainEvent + await context.db.insert(schema.transfer).values({ + ...sharedEventValues(event), + domainId: node, + ownerId: owner, + }); } export const handleNewOwner = @@ -113,10 +115,7 @@ export const handleNewOwner = event, }: { context: Context; - event: { - args: { node: Node; label: Labelhash; owner: Hex }; - block: Block; - }; + event: EventWithArgs<{ node: Node; label: Labelhash; owner: Hex }>; }) => { const { label, node, owner } = event.args; @@ -164,6 +163,15 @@ export const handleNewOwner = if (owner === zeroAddress) { await recursivelyRemoveEmptyDomainFromParentSubdomainCount(context, domain.id); } + + // log DomainEvent + await context.db.insert(schema.newOwner).values({ + ...sharedEventValues(event), + + parentDomainId: node, + domainId: subnode, + ownerId: owner, + }); }; export async function handleNewTTL({ @@ -171,9 +179,7 @@ export async function handleNewTTL({ event, }: { context: Context; - event: { - args: { node: Node; ttl: bigint }; - }; + event: EventWithArgs<{ node: Node; ttl: bigint }>; }) { const { node, ttl } = event.args; @@ -182,7 +188,12 @@ export async function handleNewTTL({ // NOTE: i'm not sure this needs to be here, as domains are never deleted (??) await context.db.update(schema.domain, { id: node }).set({ ttl }); - // TODO: log DomainEvent + // log DomainEvent + await context.db.insert(schema.newTTL).values({ + ...sharedEventValues(event), + domainId: node, + ttl, + }); } export async function handleNewResolver({ @@ -190,15 +201,15 @@ export async function handleNewResolver({ event, }: { context: Context; - event: { - args: { node: Node; resolver: Hex }; - }; + event: EventWithArgs<{ node: Node; resolver: Hex }>; }) { const { node, resolver: resolverAddress } = event.args; + const resolverId = makeResolverId(resolverAddress, node); + // if zeroing out a domain's resolver, remove the reference instead of tracking a zeroAddress Resolver // NOTE: old resolver resources are kept for event logs - if (event.args.resolver === zeroAddress) { + if (resolverAddress === zeroAddress) { await context.db .update(schema.domain, { id: node }) .set({ resolverId: null, resolvedAddressId: null }); @@ -207,12 +218,10 @@ export async function handleNewResolver({ await recursivelyRemoveEmptyDomainFromParentSubdomainCount(context, node); } else { // otherwise upsert the resolver - const resolverId = makeResolverId(resolverAddress, node); - const resolver = await upsertResolver(context, { id: resolverId, - domainId: event.args.node, - address: event.args.resolver, + domainId: node, + address: resolverAddress, }); // update the domain to point to it, and denormalize the eth addr @@ -223,5 +232,16 @@ export async function handleNewResolver({ .set({ resolverId, resolvedAddressId: resolver.addrId }); } - // TODO: log DomainEvent + // log DomainEvent + await context.db.insert(schema.newResolver).values({ + ...sharedEventValues(event), + domainId: node, + // NOTE: this actually produces a bug in the subgraph's graphql layer — `resolver` is not nullable + // but there is never a resolver record created for the zeroAddress. so if you query the + // `resolver { id }` of a NewResolver event that set the resolver to zeroAddress + // ex: newResolver(id: "3745840-2") { id resolver {id} } + // you will receive a GraphQL type error. for subgraph compatibility we re-implement this + // behavior here, but it should be entirely avoided in a v2 restructuring of the schema. + resolverId: resolverAddress === zeroAddress ? zeroAddress : resolverId, + }); } diff --git a/apps/ensnode/src/handlers/Resolver.ts b/apps/ensnode/src/handlers/Resolver.ts index 4f23caf..6006ea5 100644 --- a/apps/ensnode/src/handlers/Resolver.ts +++ b/apps/ensnode/src/handlers/Resolver.ts @@ -1,11 +1,11 @@ import { type Context } from "ponder:registry"; import schema from "ponder:schema"; import type { Node } from "ensnode-utils/types"; -import { Log } from "ponder"; import { Hex } from "viem"; -import { upsertAccount, upsertResolver } from "../lib/db-helpers"; +import { sharedEventValues, upsertAccount, upsertResolver } from "../lib/db-helpers"; import { hasNullByte, uniq } from "../lib/helpers"; import { makeResolverId } from "../lib/ids"; +import { EventWithArgs } from "../lib/ponder-helpers"; // NOTE: both subgraph and this indexer use upserts in this file because a 'Resolver' is _any_ // contract on the chain that emits an event with this signature, which may or may not actually be @@ -18,10 +18,7 @@ export async function handleAddrChanged({ event, }: { context: Context; - event: { - args: { node: Node; a: Hex }; - log: Log; - }; + event: EventWithArgs<{ node: Node; a: Hex }>; }) { const { a: address, node } = event.args; await upsertAccount(context, address); @@ -40,7 +37,12 @@ export async function handleAddrChanged({ await context.db.update(schema.domain, { id: node }).set({ resolvedAddressId: address }); } - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.addrChanged).values({ + ...sharedEventValues(event), + resolverId: id, + addrId: address, + }); } export async function handleAddressChanged({ @@ -48,10 +50,7 @@ export async function handleAddressChanged({ event, }: { context: Context; - event: { - args: { node: Node; coinType: bigint; newAddress: Hex }; - log: Log; - }; + event: EventWithArgs<{ node: Node; coinType: bigint; newAddress: Hex }>; }) { const { node, coinType, newAddress } = event.args; await upsertAccount(context, newAddress); @@ -68,7 +67,13 @@ export async function handleAddressChanged({ .update(schema.resolver, { id }) .set({ coinTypes: uniq([...(resolver.coinTypes ?? []), coinType]) }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.multicoinAddrChanged).values({ + ...sharedEventValues(event), + resolverId: id, + coinType, + addr: newAddress, + }); } export async function handleNameChanged({ @@ -76,10 +81,7 @@ export async function handleNameChanged({ event, }: { context: Context; - event: { - args: { node: Node; name: string }; - log: Log; - }; + event: EventWithArgs<{ node: Node; name: string }>; }) { const { node, name } = event.args; if (hasNullByte(name)) return; @@ -91,7 +93,12 @@ export async function handleNameChanged({ address: event.log.address, }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.nameChanged).values({ + ...sharedEventValues(event), + resolverId: id, + name, + }); } export async function handleABIChanged({ @@ -99,20 +106,24 @@ export async function handleABIChanged({ event, }: { context: Context; - event: { - args: { node: Node }; - log: Log; - }; + event: EventWithArgs<{ node: Node; contentType: bigint }>; }) { - const { node } = event.args; + const { node, contentType } = event.args; const id = makeResolverId(event.log.address, node); - const resolver = await upsertResolver(context, { + + // upsert resolver + await upsertResolver(context, { id, domainId: node, address: event.log.address, }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.abiChanged).values({ + ...sharedEventValues(event), + resolverId: id, + contentType, + }); } export async function handlePubkeyChanged({ @@ -120,20 +131,25 @@ export async function handlePubkeyChanged({ event, }: { context: Context; - event: { - args: { node: Hex }; - log: Log; - }; + event: EventWithArgs<{ node: Node; x: Hex; y: Hex }>; }) { - const { node } = event.args; + const { node, x, y } = event.args; const id = makeResolverId(event.log.address, node); - const resolver = await upsertResolver(context, { + + // upsert resolver + await upsertResolver(context, { id, domainId: node, address: event.log.address, }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.pubkeyChanged).values({ + ...sharedEventValues(event), + resolverId: id, + x, + y, + }); } export async function handleTextChanged({ @@ -141,12 +157,14 @@ export async function handleTextChanged({ event, }: { context: Context; - event: { - args: { node: Hex; indexedKey: string; key: string; value?: string }; - log: Log; - }; + event: EventWithArgs<{ + node: Node; + indexedKey: string; + key: string; + value?: string; + }>; }) { - const { node, key } = event.args; + const { node, key, value } = event.args; const id = makeResolverId(event.log.address, node); const resolver = await upsertResolver(context, { id, @@ -159,7 +177,13 @@ export async function handleTextChanged({ .update(schema.resolver, { id }) .set({ texts: uniq([...(resolver.texts ?? []), key]) }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.textChanged).values({ + ...sharedEventValues(event), + resolverId: id, + key, + value, + }); } export async function handleContenthashChanged({ @@ -167,10 +191,7 @@ export async function handleContenthashChanged({ event, }: { context: Context; - event: { - args: { node: Hex; hash: Hex }; - log: Log; - }; + event: EventWithArgs<{ node: Node; hash: Hex }>; }) { const { node, hash } = event.args; const id = makeResolverId(event.log.address, node); @@ -181,7 +202,12 @@ export async function handleContenthashChanged({ contentHash: hash, }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.contenthashChanged).values({ + ...sharedEventValues(event), + resolverId: id, + hash, + }); } export async function handleInterfaceChanged({ @@ -189,16 +215,9 @@ export async function handleInterfaceChanged({ event, }: { context: Context; - event: { - args: { - node: Hex; - interfaceID: Hex; - implementer: Hex; - }; - log: Log; - }; + event: EventWithArgs<{ node: Node; interfaceID: Hex; implementer: Hex }>; }) { - const { node } = event.args; + const { node, interfaceID, implementer } = event.args; const id = makeResolverId(event.log.address, node); await upsertResolver(context, { id, @@ -206,7 +225,13 @@ export async function handleInterfaceChanged({ address: event.log.address, }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.interfaceChanged).values({ + ...sharedEventValues(event), + resolverId: id, + interfaceID, + implementer, + }); } export async function handleAuthorisationChanged({ @@ -214,17 +239,14 @@ export async function handleAuthorisationChanged({ event, }: { context: Context; - event: { - args: { - node: Hex; - owner: Hex; - target: Hex; - isAuthorised: boolean; - }; - log: Log; - }; + event: EventWithArgs<{ + node: Node; + owner: Hex; + target: Hex; + isAuthorised: boolean; + }>; }) { - const { node } = event.args; + const { node, owner, target, isAuthorised } = event.args; const id = makeResolverId(event.log.address, node); await upsertResolver(context, { id, @@ -232,7 +254,15 @@ export async function handleAuthorisationChanged({ address: event.log.address, }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.authorisationChanged).values({ + ...sharedEventValues(event), + resolverId: id, + owner, + target, + // NOTE: the spelling difference is kept for subgraph backwards-compatibility + isAuthorized: isAuthorised, + }); } export async function handleVersionChanged({ @@ -240,16 +270,10 @@ export async function handleVersionChanged({ event, }: { context: Context; - event: { - args: { - node: Hex; - newVersion: bigint; - }; - log: Log; - }; + event: EventWithArgs<{ node: Node; newVersion: bigint }>; }) { // a version change nulls out the resolver - const { node } = event.args; + const { node, newVersion } = event.args; const id = makeResolverId(event.log.address, node); const domain = await context.db.find(schema.domain, { id: node }); @@ -270,7 +294,12 @@ export async function handleVersionChanged({ texts: null, }); - // TODO: log ResolverEvent + // log ResolverEvent + await context.db.insert(schema.versionChanged).values({ + ...sharedEventValues(event), + resolverId: id, + version: newVersion, + }); } export async function handleDNSRecordChanged({ @@ -278,14 +307,12 @@ export async function handleDNSRecordChanged({ event, }: { context: Context; - event: { - args: { - node: Hex; - name: Hex; - resource: number; - record: Hex; - }; - }; + event: EventWithArgs<{ + node: Hex; + name: Hex; + resource: number; + record: Hex; + }>; }) { // subgraph ignores } @@ -295,14 +322,12 @@ export async function handleDNSRecordDeleted({ event, }: { context: Context; - event: { - args: { - node: Hex; - name: Hex; - resource: number; - record?: Hex; - }; - }; + event: EventWithArgs<{ + node: Hex; + name: Hex; + resource: number; + record?: Hex; + }>; }) { // subgraph ignores } @@ -312,12 +337,7 @@ export async function handleDNSZonehashChanged({ event, }: { context: Context; - event: { - args: { - node: Hex; - zonehash: Hex; - }; - }; + event: EventWithArgs<{ node: Hex; zonehash: Hex }>; }) { // subgraph ignores } diff --git a/apps/ensnode/src/lib/db-helpers.ts b/apps/ensnode/src/lib/db-helpers.ts index aa12290..68abad1 100644 --- a/apps/ensnode/src/lib/db-helpers.ts +++ b/apps/ensnode/src/lib/db-helpers.ts @@ -1,6 +1,7 @@ -import type { Context } from "ponder:registry"; +import type { Context, Event } from "ponder:registry"; import schema from "ponder:schema"; import type { Address } from "viem"; +import { makeEventId } from "./ids"; export async function upsertAccount(context: Context, address: Address) { return context.db.insert(schema.account).values({ id: address }).onConflictDoNothing(); @@ -19,3 +20,12 @@ export async function upsertRegistration( ) { return context.db.insert(schema.registration).values(values).onConflictDoUpdate(values); } + +// simplifies generating the shared event column values from the ponder Event object +export function sharedEventValues(event: Omit) { + return { + id: makeEventId(event.block.number, event.log.logIndex), + blockNumber: event.block.number, + transactionID: event.transaction.hash, + }; +} diff --git a/apps/ensnode/src/lib/ponder-helpers.ts b/apps/ensnode/src/lib/ponder-helpers.ts index 82635a9..b666e44 100644 --- a/apps/ensnode/src/lib/ponder-helpers.ts +++ b/apps/ensnode/src/lib/ponder-helpers.ts @@ -1,5 +1,10 @@ +import { Event, EventNames } from "ponder:registry"; import { merge as tsDeepMerge } from "ts-deepmerge"; +export type EventWithArgs = {}> = Omit & { + args: ARGS; +}; + // makes sure start and end blocks are valid for ponder export const blockConfig = ( start: number | undefined, diff --git a/apps/ensnode/src/plugins/base.eth/handlers/Registrar.ts b/apps/ensnode/src/plugins/base.eth/handlers/Registrar.ts index 0432c4a..94b8719 100644 --- a/apps/ensnode/src/plugins/base.eth/handlers/Registrar.ts +++ b/apps/ensnode/src/plugins/base.eth/handlers/Registrar.ts @@ -51,7 +51,7 @@ export default function () { await handleNameTransferred({ context, - args: { from, to, tokenId }, + event: { ...event, args: { from, to, tokenId } }, }); }); @@ -60,7 +60,7 @@ export default function () { await handleNameRegisteredByController({ context, - args: { ...event.args, cost: 0n }, + event: { ...event, args: { ...event.args, cost: 0n } }, }); }); @@ -69,14 +69,14 @@ export default function () { await handleNameRegisteredByController({ context, - args: { ...event.args, cost: 0n }, + event: { ...event, args: { ...event.args, cost: 0n } }, }); }); ponder.on(pluginNamespace("RegistrarController:NameRenewed"), async ({ context, event }) => { await handleNameRenewedByController({ context, - args: { ...event.args, cost: 0n }, + event: { ...event, args: { ...event.args, cost: 0n } }, }); }); } diff --git a/apps/ensnode/src/plugins/eth/handlers/EthRegistrar.ts b/apps/ensnode/src/plugins/eth/handlers/EthRegistrar.ts index 58177f9..5804fd2 100644 --- a/apps/ensnode/src/plugins/eth/handlers/EthRegistrar.ts +++ b/apps/ensnode/src/plugins/eth/handlers/EthRegistrar.ts @@ -15,20 +15,20 @@ export default function () { ponder.on(pluginNamespace("BaseRegistrar:NameRenewed"), handleNameRenewed); ponder.on(pluginNamespace("BaseRegistrar:Transfer"), async ({ context, event }) => { - await handleNameTransferred({ context, args: event.args }); + await handleNameTransferred({ context, event }); }); ponder.on( pluginNamespace("EthRegistrarControllerOld:NameRegistered"), async ({ context, event }) => { // the old registrar controller just had `cost` param - await handleNameRegisteredByController({ context, args: event.args }); + await handleNameRegisteredByController({ context, event }); }, ); ponder.on( pluginNamespace("EthRegistrarControllerOld:NameRenewed"), async ({ context, event }) => { - await handleNameRenewedByController({ context, args: event.args }); + await handleNameRenewedByController({ context, event }); }, ); @@ -38,14 +38,17 @@ export default function () { // the new registrar controller uses baseCost + premium to compute cost await handleNameRegisteredByController({ context, - args: { - ...event.args, - cost: event.args.baseCost + event.args.premium, + event: { + ...event, + args: { + ...event.args, + cost: event.args.baseCost + event.args.premium, + }, }, }); }, ); ponder.on(pluginNamespace("EthRegistrarController:NameRenewed"), async ({ context, event }) => { - await handleNameRenewedByController({ context, args: event.args }); + await handleNameRenewedByController({ context, event }); }); } diff --git a/apps/ensnode/src/plugins/linea.eth/handlers/EthRegistrar.ts b/apps/ensnode/src/plugins/linea.eth/handlers/EthRegistrar.ts index f0a86cc..251847f 100644 --- a/apps/ensnode/src/plugins/linea.eth/handlers/EthRegistrar.ts +++ b/apps/ensnode/src/plugins/linea.eth/handlers/EthRegistrar.ts @@ -46,7 +46,7 @@ export default function () { await handleNameTransferred({ context, - args: { from, to, tokenId }, + event: { ...event, args: { from, to, tokenId } }, }); }); @@ -56,10 +56,7 @@ export default function () { async ({ context, event }) => { await handleNameRegisteredByController({ context, - args: { - ...event.args, - cost: 0n, - }, + event: { ...event, args: { ...event.args, cost: 0n } }, }); }, ); @@ -70,10 +67,7 @@ export default function () { async ({ context, event }) => { await handleNameRegisteredByController({ context, - args: { - ...event.args, - cost: 0n, - }, + event: { ...event, args: { ...event.args, cost: 0n } }, }); }, ); @@ -84,14 +78,17 @@ export default function () { // the new registrar controller uses baseCost + premium to compute cost await handleNameRegisteredByController({ context, - args: { - ...event.args, - cost: event.args.baseCost + event.args.premium, + event: { + ...event, + args: { + ...event.args, + cost: event.args.baseCost + event.args.premium, + }, }, }); }, ); ponder.on(pluginNamespace("EthRegistrarController:NameRenewed"), async ({ context, event }) => { - await handleNameRenewedByController({ context, args: event.args }); + await handleNameRenewedByController({ context, event }); }); } diff --git a/package.json b/package.json index db60d39..1f35c22 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint:ci": "biome ci" }, "devDependencies": { - "@biomejs/biome": "^1.9.4" + "@biomejs/biome": "^1.9.4", + "typescript": "catalog:" }, "engines": { "node": ">=18.20.5" diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index e38f2d0..bd65955 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -1,6 +1,10 @@ import { onchainTable, relations } from "ponder"; import type { Address } from "viem"; +/** + * Domain + */ + export const domain = onchainTable("domains", (t) => ({ // The namehash of the name id: t.hex().primaryKey(), @@ -39,62 +43,49 @@ export const domain = onchainTable("domains", (t) => ({ // The expiry date for the domain, from either the registration, or the wrapped domain if PCC is burned expiryDate: t.bigint("expiry_date"), - - // "The events associated with the domain" - // events: [DomainEvent!]! @derivedFrom(field: "domain") })); export const domainRelations = relations(domain, ({ one, many }) => ({ - resolvedAddress: one(account, { - fields: [domain.resolvedAddressId], - references: [account.id], - }), - owner: one(account, { - fields: [domain.ownerId], - references: [account.id], - }), - parent: one(domain, { - fields: [domain.parentId], - references: [domain.id], - }), - resolver: one(resolver, { - fields: [domain.resolverId], - references: [resolver.id], - }), + resolvedAddress: one(account, { fields: [domain.resolvedAddressId], references: [account.id] }), + owner: one(account, { fields: [domain.ownerId], references: [account.id] }), + parent: one(domain, { fields: [domain.parentId], references: [domain.id] }), + resolver: one(resolver, { fields: [domain.resolverId], references: [resolver.id] }), subdomains: many(domain, { relationName: "parent" }), - registrant: one(account, { - fields: [domain.registrantId], - references: [account.id], - }), - wrappedOwner: one(account, { - fields: [domain.wrappedOwnerId], - references: [account.id], - }), - - // The wrapped domain associated with the domain - wrappedDomain: one(wrappedDomain, { - fields: [domain.id], - references: [wrappedDomain.domainId], - }), + registrant: one(account, { fields: [domain.registrantId], references: [account.id] }), + wrappedOwner: one(account, { fields: [domain.wrappedOwnerId], references: [account.id] }), + wrappedDomain: one(wrappedDomain, { fields: [domain.id], references: [wrappedDomain.domainId] }), + registration: one(registration, { fields: [domain.id], references: [registration.domainId] }), - // The registration associated with the domain - registration: one(registration, { - fields: [domain.id], - references: [registration.domainId], - }), + // event relations + transfers: many(transfer), + newOwners: many(newOwner), + newResolvers: many(newResolver), + newTTLs: many(newTTL), + wrappedTransfers: many(wrappedTransfer), + nameWrappeds: many(nameWrapped), + nameUnwrappeds: many(nameUnwrapped), + fusesSets: many(fusesSet), + expiryExtendeds: many(expiryExtended), })); +/** + * Account + */ + export const account = onchainTable("accounts", (t) => ({ id: t.hex().primaryKey(), })); export const accountRelations = relations(account, ({ many }) => ({ - // account has many domains domains: many(domain), - // TODO: has many wrapped domains - // TODO: has many registrations + wrappedDomains: many(wrappedDomain), + registrations: many(registration), })); +/** + * Resolver + */ + export const resolver = onchainTable("resolvers", (t) => ({ // The unique identifier for this resolver, which is a concatenation of the domain namehash and the resolver address id: t.text().primaryKey(), @@ -113,21 +104,29 @@ export const resolver = onchainTable("resolvers", (t) => ({ // The set of observed SLIP-44 coin types for this resolver // NOTE: we avoid .notNull.default([]) to match subgraph behavior coinTypes: t.bigint("coin_types").array(), - - // TODO: has many events })); -export const resolverRelations = relations(resolver, ({ one }) => ({ - addr: one(account, { - fields: [resolver.addrId], - references: [account.id], - }), - domain: one(domain, { - fields: [resolver.domainId], - references: [domain.id], - }), +export const resolverRelations = relations(resolver, ({ one, many }) => ({ + addr: one(account, { fields: [resolver.addrId], references: [account.id] }), + domain: one(domain, { fields: [resolver.domainId], references: [domain.id] }), + + // event relations + addrChangeds: many(addrChanged), + multicoinAddrChangeds: many(multicoinAddrChanged), + nameChangeds: many(nameChanged), + abiChangeds: many(abiChanged), + pubkeyChangeds: many(pubkeyChanged), + textChangeds: many(textChanged), + contenthashChangeds: many(contenthashChanged), + interfaceChangeds: many(interfaceChanged), + authorisationChangeds: many(authorisationChanged), + versionChangeds: many(versionChanged), })); +/** + * Registration + */ + export const registration = onchainTable("registrations", (t) => ({ // The unique identifier of the registration id: t.hex().primaryKey(), @@ -143,22 +142,22 @@ export const registration = onchainTable("registrations", (t) => ({ registrantId: t.hex("registrant_id").notNull(), // The human-readable label name associated with the domain registration labelName: t.text(), - - // The events associated with the domain registration - // TODO: events })); -export const registrationRelations = relations(registration, ({ one }) => ({ - domain: one(domain, { - fields: [registration.domainId], - references: [domain.id], - }), - registrant: one(account, { - fields: [registration.registrantId], - references: [account.id], - }), +export const registrationRelations = relations(registration, ({ one, many }) => ({ + domain: one(domain, { fields: [registration.domainId], references: [domain.id] }), + registrant: one(account, { fields: [registration.registrantId], references: [account.id] }), + + // event relations + nameRegistereds: many(nameRegistered), + nameReneweds: many(nameRenewed), + nameTransferreds: many(nameTransferred), })); +/** + * Wrapped Domain + */ + export const wrappedDomain = onchainTable("wrapped_domains", (t) => ({ // The unique identifier for each instance of the WrappedDomain entity id: t.hex().primaryKey(), @@ -175,12 +174,275 @@ export const wrappedDomain = onchainTable("wrapped_domains", (t) => ({ })); export const wrappedDomainRelations = relations(wrappedDomain, ({ one }) => ({ - domain: one(domain, { - fields: [wrappedDomain.domainId], - references: [domain.id], + domain: one(domain, { fields: [wrappedDomain.domainId], references: [domain.id] }), + owner: one(account, { fields: [wrappedDomain.ownerId], references: [account.id] }), +})); + +/** + * Events + */ + +const sharedEventColumns = (t: any) => ({ + id: t.text().primaryKey(), + blockNumber: t.integer("block_number").notNull(), + transactionID: t.hex("transaction_id").notNull(), +}); + +const domainEvent = (t: any) => ({ + ...sharedEventColumns(t), + domainId: t.hex("domain_id").notNull(), +}); + +// Domain Event Entities + +export const transfer = onchainTable("transfers", (t) => ({ + ...domainEvent(t), + ownerId: t.hex("owner_id").notNull(), +})); + +export const newOwner = onchainTable("new_owners", (t) => ({ + ...domainEvent(t), + ownerId: t.hex("owner_id").notNull(), + parentDomainId: t.hex("parent_domain_id").notNull(), +})); + +export const newResolver = onchainTable("new_resolvers", (t) => ({ + ...domainEvent(t), + resolverId: t.text("resolver_id").notNull(), +})); + +export const newTTL = onchainTable("new_ttls", (t) => ({ + ...domainEvent(t), + ttl: t.bigint().notNull(), +})); + +export const wrappedTransfer = onchainTable("wrapped_transfers", (t) => ({ + ...domainEvent(t), + ownerId: t.hex("owner_id").notNull(), +})); + +export const nameWrapped = onchainTable("name_wrapped", (t) => ({ + ...domainEvent(t), + name: t.text(), + fuses: t.integer().notNull(), + ownerId: t.hex("owner_id").notNull(), + expiryDate: t.bigint("expiry_date").notNull(), +})); + +export const nameUnwrapped = onchainTable("name_unwrapped", (t) => ({ + ...domainEvent(t), + ownerId: t.hex("owner_id").notNull(), +})); + +export const fusesSet = onchainTable("fuses_set", (t) => ({ + ...domainEvent(t), + fuses: t.integer().notNull(), +})); + +export const expiryExtended = onchainTable("expiry_extended", (t) => ({ + ...domainEvent(t), + expiryDate: t.bigint("expiry_date").notNull(), +})); + +// Registration Event Entities + +const registrationEvent = (t: any) => ({ + ...sharedEventColumns(t), + registrationId: t.hex("registration_id").notNull(), +}); + +export const nameRegistered = onchainTable("name_registered", (t) => ({ + ...registrationEvent(t), + registrantId: t.hex("registrant_id").notNull(), + expiryDate: t.bigint("expiry_date").notNull(), +})); + +export const nameRenewed = onchainTable("name_renewed", (t) => ({ + ...registrationEvent(t), + expiryDate: t.bigint("expiry_date").notNull(), +})); + +export const nameTransferred = onchainTable("name_transferred", (t) => ({ + ...registrationEvent(t), + newOwnerId: t.hex("new_owner_id").notNull(), +})); + +// Resolver Event Entities + +const resolverEvent = (t: any) => ({ + ...sharedEventColumns(t), + resolverId: t.text("resolver_id").notNull(), +}); + +export const addrChanged = onchainTable("addr_changed", (t) => ({ + ...resolverEvent(t), + addrId: t.hex("addr_id").notNull(), +})); + +export const multicoinAddrChanged = onchainTable("multicoin_addr_changed", (t) => ({ + ...resolverEvent(t), + coinType: t.bigint("coin_type").notNull(), + addr: t.hex().notNull(), +})); + +export const nameChanged = onchainTable("name_changed", (t) => ({ + ...resolverEvent(t), + name: t.text().notNull(), +})); + +export const abiChanged = onchainTable("abi_changed", (t) => ({ + ...resolverEvent(t), + contentType: t.bigint("content_type").notNull(), +})); + +export const pubkeyChanged = onchainTable("pubkey_changed", (t) => ({ + ...resolverEvent(t), + x: t.hex().notNull(), + y: t.hex().notNull(), +})); + +export const textChanged = onchainTable("text_changed", (t) => ({ + ...resolverEvent(t), + key: t.text().notNull(), + value: t.text(), +})); + +export const contenthashChanged = onchainTable("contenthash_changed", (t) => ({ + ...resolverEvent(t), + hash: t.hex().notNull(), +})); + +export const interfaceChanged = onchainTable("interface_changed", (t) => ({ + ...resolverEvent(t), + interfaceID: t.hex("interface_id").notNull(), + implementer: t.hex().notNull(), +})); + +export const authorisationChanged = onchainTable("authorisation_changed", (t) => ({ + ...resolverEvent(t), + owner: t.hex().notNull(), + target: t.hex().notNull(), + isAuthorized: t.boolean("is_authorized").notNull(), +})); + +export const versionChanged = onchainTable("version_changed", (t) => ({ + ...resolverEvent(t), + version: t.bigint().notNull(), +})); + +/** + * Event Relations + */ + +// Domain Event Relations + +export const transferRelations = relations(transfer, ({ one }) => ({ + domain: one(domain, { fields: [transfer.domainId], references: [domain.id] }), + owner: one(account, { fields: [transfer.ownerId], references: [account.id] }), +})); + +export const newOwnerRelations = relations(newOwner, ({ one }) => ({ + domain: one(domain, { fields: [newOwner.domainId], references: [domain.id] }), + owner: one(account, { fields: [newOwner.ownerId], references: [account.id] }), + parentDomain: one(domain, { fields: [newOwner.parentDomainId], references: [domain.id] }), +})); + +export const newResolverRelations = relations(newResolver, ({ one }) => ({ + domain: one(domain, { fields: [newResolver.domainId], references: [domain.id] }), + resolver: one(resolver, { fields: [newResolver.resolverId], references: [resolver.id] }), +})); + +export const newTTLRelations = relations(newTTL, ({ one }) => ({ + domain: one(domain, { fields: [newTTL.domainId], references: [domain.id] }), +})); + +export const wrappedTransferRelations = relations(wrappedTransfer, ({ one }) => ({ + domain: one(domain, { fields: [wrappedTransfer.domainId], references: [domain.id] }), + owner: one(account, { fields: [wrappedTransfer.ownerId], references: [account.id] }), +})); + +export const nameWrappedRelations = relations(nameWrapped, ({ one }) => ({ + domain: one(domain, { fields: [nameWrapped.domainId], references: [domain.id] }), + owner: one(account, { fields: [nameWrapped.ownerId], references: [account.id] }), +})); + +export const nameUnwrappedRelations = relations(nameUnwrapped, ({ one }) => ({ + domain: one(domain, { fields: [nameUnwrapped.domainId], references: [domain.id] }), + owner: one(account, { fields: [nameUnwrapped.ownerId], references: [account.id] }), +})); + +export const fusesSetRelations = relations(fusesSet, ({ one }) => ({ + domain: one(domain, { fields: [fusesSet.domainId], references: [domain.id] }), +})); + +export const expiryExtendedRelations = relations(expiryExtended, ({ one }) => ({ + domain: one(domain, { fields: [expiryExtended.domainId], references: [domain.id] }), +})); + +// Registration Event Relations + +export const nameRegisteredRelations = relations(nameRegistered, ({ one }) => ({ + registration: one(registration, { + fields: [nameRegistered.registrationId], + references: [registration.id], + }), + registrant: one(account, { fields: [nameRegistered.registrantId], references: [account.id] }), +})); + +export const nameRenewedRelations = relations(nameRenewed, ({ one }) => ({ + registration: one(registration, { + fields: [nameRenewed.registrationId], + references: [registration.id], }), - owner: one(account, { - fields: [wrappedDomain.ownerId], - references: [account.id], +})); + +export const nameTransferredRelations = relations(nameTransferred, ({ one }) => ({ + registration: one(registration, { + fields: [nameTransferred.registrationId], + references: [registration.id], }), + newOwner: one(account, { fields: [nameTransferred.newOwnerId], references: [account.id] }), +})); + +// Resolver Event Relations + +export const addrChangedRelations = relations(addrChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [addrChanged.resolverId], references: [resolver.id] }), + addr: one(account, { fields: [addrChanged.addrId], references: [account.id] }), +})); + +export const multicoinAddrChangedRelations = relations(multicoinAddrChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [multicoinAddrChanged.resolverId], references: [resolver.id] }), +})); + +export const nameChangedRelations = relations(nameChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [nameChanged.resolverId], references: [resolver.id] }), +})); + +export const abiChangedRelations = relations(abiChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [abiChanged.resolverId], references: [resolver.id] }), +})); + +export const pubkeyChangedRelations = relations(pubkeyChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [pubkeyChanged.resolverId], references: [resolver.id] }), +})); + +export const textChangedRelations = relations(textChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [textChanged.resolverId], references: [resolver.id] }), +})); + +export const contenthashChangedRelations = relations(contenthashChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [contenthashChanged.resolverId], references: [resolver.id] }), +})); + +export const interfaceChangedRelations = relations(interfaceChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [interfaceChanged.resolverId], references: [resolver.id] }), +})); + +export const authorisationChangedRelations = relations(authorisationChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [authorisationChanged.resolverId], references: [resolver.id] }), +})); + +export const versionChangedRelations = relations(versionChanged, ({ one }) => ({ + resolver: one(resolver, { fields: [versionChanged.resolverId], references: [resolver.id] }), })); diff --git a/packages/ponder-subgraph-api/package.json b/packages/ponder-subgraph-api/package.json index 548281d..5c85fca 100644 --- a/packages/ponder-subgraph-api/package.json +++ b/packages/ponder-subgraph-api/package.json @@ -14,13 +14,13 @@ "./middleware": "./src/middleware.ts" }, "scripts": { + "typecheck": "tsc --noEmit", "lint:ci": "biome ci" }, "dependencies": { "@escape.tech/graphql-armor-max-aliases": "^2.6.0", "@escape.tech/graphql-armor-max-depth": "^2.4.0", "@escape.tech/graphql-armor-max-tokens": "^2.5.0", - "change-case": "^5.4.4", "dataloader": "^2.2.3", "drizzle-orm": "catalog:", "graphql": "^16.10.0", @@ -28,8 +28,8 @@ "graphql-yoga": "^5.10.9" }, "devDependencies": { - "@namehash/shared-configs": "workspace:", "@biomejs/biome": "catalog:", + "@namehash/shared-configs": "workspace:", "@types/node": "catalog:", "hono": "catalog:", "typescript": "catalog:" diff --git a/packages/ponder-subgraph-api/src/graphql.ts b/packages/ponder-subgraph-api/src/graphql.ts index e114071..a1d7d91 100644 --- a/packages/ponder-subgraph-api/src/graphql.ts +++ b/packages/ponder-subgraph-api/src/graphql.ts @@ -31,13 +31,13 @@ export type OnchainTable< enableRLS: () => Omit, "enableRLS">; }; -import { pascalCase } from "change-case"; import DataLoader from "dataloader"; import { type Column, Many, One, type SQL, + Subquery, type TableRelationalConfig, and, arrayContained, @@ -48,6 +48,8 @@ import { eq, extractTablesRelationalConfig, getTableColumns, + getTableName, + getTableUniqueName, gt, gte, inArray, @@ -60,8 +62,11 @@ import { notInArray, notLike, or, + relations, + sql, } from "drizzle-orm"; import { + type PgColumnBuilderBase, type PgEnum, PgEnumColumn, PgInteger, @@ -70,7 +75,10 @@ import { PgTableExtraConfig, TableConfig, isPgEnum, + pgTable, } from "drizzle-orm/pg-core"; +import { PgViewBase } from "drizzle-orm/pg-core/view-base"; +import { Relation, TablesRelationalConfig } from "drizzle-orm/relations"; import { GraphQLBoolean, GraphQLEnumType, @@ -82,6 +90,7 @@ import { GraphQLInputObjectType, type GraphQLInputType, GraphQLInt, + GraphQLInterfaceType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -91,6 +100,7 @@ import { GraphQLString, } from "graphql"; import { GraphQLJSON } from "graphql-scalars"; +import { capitalize, intersectionOf } from "./helpers"; type Parent = Record; type Context = { @@ -118,9 +128,58 @@ const OrderDirectionEnum = new GraphQLEnumType({ }, }); -export function buildGraphQLSchema(schema: Schema): GraphQLSchema { - const tablesConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers); +/** + * the following type describes: + * 1. `types` — mapping a polymorphic type name to the set of entities that implement that interface + * ex: DomainEvent -> [TransferEvent, ...] + * 2. `fields` — mapping a typeName to the polymorphic type it represents + * ex: Domain.events -> DomainEvent + * + * NOTE: in future implementations of ponder, this information could be provided by the schema + * using materialized views, and most/all of this code can be removed. + */ +export interface PolymorphicConfig { + types: Record[]>; + fields: Record; +} + +export function buildGraphQLSchema( + schema: Schema, + polymorphicConfig: PolymorphicConfig = { types: {}, fields: {} }, +): GraphQLSchema { + // first, construct TablesRelationConfig with the existing schema. this is necessary because + // we need access to relations by table, which this helper resolves + const _tablesConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers); + + // next, remap polymorphicConfig.types to interfaceTypeName -> implementing TableRelationalConfig[] + const polymorphicTableConfigs = Object.fromEntries( + Object.entries(polymorphicConfig.types).map(([interfaceTypeName, implementingTables]) => [ + interfaceTypeName, + implementingTables + .map((table) => getTableUniqueName(table)) + .map((tableName) => _tablesConfig.tables[_tablesConfig.tableNamesMap[tableName]!]!), + ]), + ); + + // use this TablesRelationalConfig to generate the intersection table & relationships + // and inject our 'fake' intersection tables into the schema so filters and orderBy entities are + // auto generated as normal. + Object.assign( + schema, + ...Object.keys(polymorphicConfig.types).map((interfaceTypeName) => + getIntersectionTableSchema(interfaceTypeName, polymorphicTableConfigs[interfaceTypeName]!), + ), + ); + // restructure `Type.fieldName` into `[Type, fieldName]` for simpler logic later + const polymorphicFields = Object.entries(polymorphicConfig.fields) + // split fieldPath into segments + .map<[[string, string], string]>(([fieldPath, interfaceTypeName]) => [ + fieldPath.split(".") as [string, string], + interfaceTypeName, + ]); + + const tablesConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers); const tables = Object.values(tablesConfig.tables) as TableRelationalConfig[]; const enums = Object.entries(schema).filter((el): el is [string, PgEnum<[string, ...string[]]>] => @@ -154,7 +213,7 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { // TODO: relationships i.e. parent__labelName iff necessary entityOrderByEnums[table.tsName] = new GraphQLEnumType({ - name: `${pascalCase(table.tsName)}_orderBy`, + name: `${getSubgraphEntityName(table.tsName)}_orderBy`, values, }); } @@ -162,7 +221,7 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { const entityFilterTypes: Record = {}; for (const table of tables) { const filterType = new GraphQLInputObjectType({ - name: `${table.tsName}Filter`, + name: `${table.tsName}_filter`, fields: () => { const filterFields: GraphQLInputFieldConfigMap = { // Logical operators @@ -231,11 +290,46 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { } const entityTypes: Record> = {}; + const interfaceTypes: Record = {}; const entityPageTypes: Record = {}; + // for each polymorphic interface type name + for (const interfaceTypeName of Object.keys(polymorphicTableConfigs)) { + const table = tablesConfig.tables[interfaceTypeName]!; + + // construct a GraphQLInterfaceType representing the intersection table + interfaceTypes[interfaceTypeName] = new GraphQLInterfaceType({ + name: interfaceTypeName, + fields: () => { + const fieldConfigMap: GraphQLFieldConfigMap = {}; + + for (const [columnName, column] of Object.entries(table.columns)) { + const type = columnToGraphQLCore(column, enumTypes); + fieldConfigMap[columnName] = { + type: column.notNull ? new GraphQLNonNull(type) : type, + }; + } + + return fieldConfigMap; + }, + }); + } + + // construct object type for each entity for (const table of tables) { + // don't make entityTypes for our fake intersection tables + if (isInterfaceType(polymorphicConfig, table.tsName)) continue; + + const entityTypeName = getSubgraphEntityName(table.tsName); entityTypes[table.tsName] = new GraphQLObjectType({ - name: pascalCase(table.tsName), // NOTE: PascalCase to match subgraph + name: entityTypeName, + interfaces: Object.entries(polymorphicTableConfigs) + // if this entity implements an interface... + .filter(([, implementingTables]) => + implementingTables.map((table) => table.tsName).includes(table.tsName), + ) + // include the interfaceType here + .map(([interfaceTypeName]) => interfaceTypes[interfaceTypeName]!), fields: () => { const fieldConfigMap: GraphQLFieldConfigMap = {}; @@ -346,6 +440,7 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { return executePluralQuery( referencedTable, + schema[table.tsName] as PgTable, context.drizzle, args, relationalConditions, @@ -359,6 +454,23 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { } } + // Polymorphic Plural Entity Fields + // NOTE: overrides any automatic field definitions from the above + polymorphicFields + // filter by fields on this type + .filter(([[parent]]) => parent === entityTypeName) + // define each polymorphic plural field + .forEach(([[, fieldName], interfaceTypeName]) => { + fieldConfigMap[fieldName] = definePolymorphicPluralField({ + schema, + interfaceType: interfaceTypes[interfaceTypeName]!, + filterType: entityFilterTypes[interfaceTypeName]!, + orderByType: entityOrderByEnums[interfaceTypeName]!, + intersectionTableConfig: tablesConfig.tables[interfaceTypeName]!, + implementingTableConfigs: polymorphicTableConfigs[interfaceTypeName]!, + }); + }); + return fieldConfigMap; }, }); @@ -370,6 +482,9 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { const queryFields: Record> = {}; for (const table of tables) { + // skip making top level query fields for our fake intersection tables + if (isInterfaceType(polymorphicConfig, table.tsName)) continue; + const entityType = entityTypes[table.tsName]!; const entityPageType = entityPageTypes[table.tsName]!; const entityFilterType = entityFilterTypes[table.tsName]!; @@ -392,11 +507,8 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { resolve: async (_parent, args, context) => { const loader = context.getDataLoader({ table }); - // The `args` object here should be a valid `where` argument that - // uses the `eq` shorthand for each primary key column. - const encodedId = args.id as string; - - return loader.load(encodedId); + // NOTE(subgraph-compat): subgraph api requires an `id` string value on all records + return loader.load(args.id as string); }, }; @@ -413,11 +525,28 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema { skip: { type: GraphQLInt }, }, resolve: async (_parent, args: PluralArgs, context, info) => { - return executePluralQuery(table, context.drizzle, args); + return executePluralQuery(table, schema[table.tsName] as PgTable, context.drizzle, args); }, }; } + // Polymorphic Plural Query Fields + // NOTE: overrides any automatic field definitions from the above + polymorphicFields + // filter by fieldPaths that have a parent of Query + .filter(([[parent]]) => parent === "Query") + // build each polymorphic plural field + .forEach(([[, fieldName], interfaceTypeName]) => { + queryFields[fieldName] = definePolymorphicPluralField({ + schema, + interfaceType: interfaceTypes[interfaceTypeName]!, + filterType: entityFilterTypes[interfaceTypeName]!, + orderByType: entityOrderByEnums[interfaceTypeName]!, + intersectionTableConfig: tablesConfig.tables[interfaceTypeName]!, + implementingTableConfigs: polymorphicTableConfigs[interfaceTypeName]!, + }); + }); + queryFields._meta = { type: GraphQLMeta, resolve: async (_source, _args, context) => { @@ -531,15 +660,11 @@ const innerType = (type: GraphQLOutputType): GraphQLScalarType | GraphQLEnumType async function executePluralQuery( table: TableRelationalConfig, - drizzle: Drizzle<{ [key: string]: OnchainTable }>, + from: PgTable | Subquery | PgViewBase | SQL, + drizzle: Drizzle, args: PluralArgs, extraConditions: (SQL | undefined)[] = [], ) { - const rawTable = drizzle._.fullSchema[table.tsName]; - const baseQuery = drizzle.query[table.tsName]; - if (rawTable === undefined || baseQuery === undefined) - throw new Error(`Internal error: Table "${table.tsName}" not found in RQB`); - const limit = args.first ?? DEFAULT_LIMIT; if (limit > MAX_LIMIT) { throw new Error(`Invalid limit. Got ${limit}, expected <=${MAX_LIMIT}.`); @@ -558,12 +683,13 @@ async function executePluralQuery( const whereConditions = buildWhereConditions(args.where, table.columns); - const rows = await baseQuery.findMany({ - where: and(...whereConditions, ...extraConditions), - orderBy, - limit, - offset: skip, - }); + const rows = await drizzle + .select() + .from(from) + .where(and(...whereConditions, ...extraConditions)) + .orderBy(...orderBy) + .limit(limit) + .offset(skip); return rows; } @@ -745,3 +871,220 @@ function getColumnTsName(column: Column) { const tableColumns = getTableColumns(column.table); return Object.entries(tableColumns).find(([_, c]) => c.name === column.name)![0]; } + +// the subgraph's GraphQL types are just the capitalized version of ponder's tsName +function getSubgraphEntityName(tsName: string) { + return capitalize(tsName); +} + +function isInterfaceType(polymorphicConfig: PolymorphicConfig, columnName: string) { + return columnName in polymorphicConfig.types; +} + +// defines a table and relations that is the intersection of the provided `tableConfigs` +function getIntersectionTableSchema( + interfaceTypeName: string, + tableConfigs: TableRelationalConfig[], +) { + if (tableConfigs.length === 0) throw new Error("Must have some tables to intersect"); + + const baseColumns = tableConfigs[0]!.columns; + const baseRelations = tableConfigs[0]!.relations; + + // compute the common columnNames + const commonColumnNames = intersectionOf( + tableConfigs.map((table) => Object.keys(table.columns)), // + ); + + // compute the common relationshipNames + const commonRelationsNames = intersectionOf( + tableConfigs.map((table) => Object.keys(table.relations)), + ); + + // define a pgTable by cloning the common columns w/ builder functions + // TODO: can we more easily clone theses instead of using builder fns? Object.assign? + // NOTE: it's important that this table's dbName is intersection_table, as that is what the + // UNION ALL subquery is aliased to later + // TODO: can we use drizzle's .with or something to avoid the magic string? + const intersectionTable = pgTable("intersection_table", (t) => { + function getColumnBuilder(column: Column) { + const sqlType = column.getSQLType(); + + // special case for bigint which returns "numeric(78)" + if (sqlType === "numeric(78)") { + return t.numeric({ precision: 78 }); + } + + // handle standard types, removing any parameters from the SQLType + // @ts-expect-error we know it is a valid key now + const built = t[sqlType.split("(")[0]](); + + // include .primaryKey if necessary + return column.primary ? built.primaryKey() : built; + } + + const columnMap: Record = {}; + + for (const columnName of commonColumnNames) { + const baseColumn = baseColumns[columnName]!; + columnMap[columnName] = getColumnBuilder(baseColumn).notNull(baseColumn.notNull); + } + + return columnMap; + }); + + // define the relationships for this table by cloning the common relationships + const intersectionTableRelations = relations(intersectionTable, ({ one }) => + commonRelationsNames.reduce>>((memo, relationName) => { + const relation = baseRelations[relationName]; + + if (is(relation, One)) { + memo[relationName] = one(relation.referencedTable, relation.config as any); + } else if (is(relation, Many)) { + // NOTE: unimplemented — only One relations are necessary for relationalConditions later + } + + return memo; + }, {}), + ); + + return { + [interfaceTypeName]: intersectionTable, + [`${interfaceTypeName}Relations`]: intersectionTableRelations, + }; +} + +// produces Record that is the union of all columns in tables +function getColumnsUnion(tables: TableRelationalConfig[]): Record { + return tables.reduce( + (memo, table) => ({ + ...memo, + ...table.columns, + }), + {}, + ); +} + +// builds a drizzle subquery from the set of provided tables with given `where` filter +function buildUnionAllQuery( + drizzle: Drizzle<{ [key: string]: OnchainTable }>, + schema: Schema, + tables: TableRelationalConfig[], + where: Record = {}, +) { + const allColumns = getColumnsUnion(tables); + const allColumnNames = Object.keys(allColumns).sort(); + + // builds a subquery per-table like `SELECT columns... from :table (WHERE fk = fkv)` + const subqueries = tables.map((table) => { + // NOTE: every subquery of union must have the same columns of the same types so we + // build select object with nulls for missing columns, manually casting them to the correct type + const selectAllColumnsIncludingNulls = allColumnNames.reduce((memo, columnName) => { + const column = allColumns[columnName]!; + return { + ...memo, + [columnName]: sql + .raw(`${table.columns[columnName]?.name ?? "NULL"}::${column.getSQLType()}`) + .as(column.name), + }; + }, {}); + + // apply the relation filter at subquery level to minimize merged data + // NOTE: that we use this table's Column so the generated sql references the correct table + const relationalConditions: SQL[] = Object.entries(where).map( + ([foreignKeyName, foreignKeyValue]) => eq(table.columns[foreignKeyName]!, foreignKeyValue), + ); + + return drizzle + .select({ + ...selectAllColumnsIncludingNulls, + // inject __typename into each subquery + __typename: sql.raw(`'${getSubgraphEntityName(table.tsName)}'`).as("__typename"), + }) + .from(schema[table.tsName] as PgTable) + .where(and(...relationalConditions)) + .$dynamic(); + }); + + // joins the subqueries with UNION ALL and aliases it to `intersection_table` + return subqueries + .reduce((memo, fragment, i) => (i === 0 ? fragment : memo.unionAll(fragment))) + .as("intersection_table"); +} + +/** + * creates the GraphQLFieldConfig for a polymorphic plural field + * + * @param schema the database schema containing table definitions + * @param interfaceType the GraphQL interface type for the polymorphic field + * @param filterType the GraphQL input type for filtering records + * @param orderByType the GraphQL enum type for ordering records + * @param intersectionTableConfig the TableConfig representing the intersection + * of `implementingTableConfigs` + * @param implementingTableConfigs array of table configs that implement the interface + */ +function definePolymorphicPluralField({ + schema, + interfaceType, + filterType, + orderByType, + intersectionTableConfig, + implementingTableConfigs, +}: { + schema: Schema; + interfaceType: GraphQLInterfaceType; + filterType: GraphQLInputObjectType; + orderByType: GraphQLEnumType; + intersectionTableConfig: TableRelationalConfig; + implementingTableConfigs: TableRelationalConfig[]; +}): GraphQLFieldConfig { + return { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(interfaceType))), + args: { + where: { type: filterType }, + orderBy: { type: orderByType }, + orderDirection: { type: OrderDirectionEnum }, + first: { type: GraphQLInt }, + skip: { type: GraphQLInt }, + }, + resolve: async (parent, args: PluralArgs, { drizzle }, info) => { + // find the relation field that references the parent type + const foreignKeyName = getForeignKeyFieldName(intersectionTableConfig, info.parentType.name); + + // include it in the relationalFilter iff necessary + const relationalFilter = foreignKeyName ? { [foreignKeyName]: parent.id } : {}; + + // construct a UNION ALL subquery + const subquery = buildUnionAllQuery( + drizzle, + schema, + implementingTableConfigs, + relationalFilter, + ); + + // pass it to executePluralQuery as usual + return executePluralQuery(intersectionTableConfig, subquery, drizzle, args); + }, + }; +} + +// finds the foreign key name in a table that references a given GraphQL parentTypeName +function getForeignKeyFieldName(table: TableRelationalConfig, parentTypeName: string) { + // 1. find the first relation field that references the parent type name + const relationName = Object.keys(table.relations).find( + (relationName) => getSubgraphEntityName(relationName) === parentTypeName, + ); + if (!relationName) return; + + // ignore if this isn't a One relation + const relation = table.relations[relationName]; + if (!is(relation, One)) return; + + // 2. find the columnName of the correct column + const fkEntry = Object.entries(relation.config?.fields?.[0]?.table ?? {}).find( + ([_, column]) => column.name === relation.config?.fields?.[0]?.name, + ); + + // 3. columnName is the name of the foreign key column in `table` + return fkEntry?.[0]; +} diff --git a/packages/ponder-subgraph-api/src/helpers.ts b/packages/ponder-subgraph-api/src/helpers.ts new file mode 100644 index 0000000..a6047da --- /dev/null +++ b/packages/ponder-subgraph-api/src/helpers.ts @@ -0,0 +1,8 @@ +// https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore?tab=readme-ov-file#_intersection +export const intersectionOf = (arrays: T[][]) => + arrays.reduce((a, b) => a.filter((c) => b.includes(c))); + +export const capitalize = (str: string): string => { + if (!str) return str; + return `${str.charAt(0).toUpperCase()}${str.slice(1)}`; +}; diff --git a/packages/ponder-subgraph-api/src/middleware.ts b/packages/ponder-subgraph-api/src/middleware.ts index 093b49a..d9cd3bf 100644 --- a/packages/ponder-subgraph-api/src/middleware.ts +++ b/packages/ponder-subgraph-api/src/middleware.ts @@ -11,7 +11,12 @@ import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth"; import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens"; import { type YogaServerInstance, createYoga } from "graphql-yoga"; import { createMiddleware } from "hono/factory"; -import { type Schema, buildDataLoaderCache, buildGraphQLSchema } from "./graphql"; +import { + PolymorphicConfig, + type Schema, + buildDataLoaderCache, + buildGraphQLSchema, +} from "./graphql"; /** * Middleware for GraphQL with an interactive web view. @@ -27,6 +32,7 @@ import { type Schema, buildDataLoaderCache, buildGraphQLSchema } from "./graphql */ export const graphql = ({ schema, + polymorphicConfig, // Default limits are from Apollo: // https://www.apollographql.com/blog/prevent-graph-misuse-with-operation-size-and-complexity-limit maxOperationTokens = 1000, @@ -34,6 +40,7 @@ export const graphql = ({ maxOperationAliases = 30, }: { schema: Schema; + polymorphicConfig?: PolymorphicConfig; maxOperationTokens?: number; maxOperationDepth?: number; maxOperationAliases?: number; @@ -43,7 +50,7 @@ export const graphql = ({ return createMiddleware(async (c) => { if (yoga === undefined) { const metadataStore = c.get("metadataStore"); - const graphqlSchema = buildGraphQLSchema(schema); + const graphqlSchema = buildGraphQLSchema(schema, polymorphicConfig); const drizzle = c.get("db"); yoga = createYoga({ diff --git a/packages/ponder-subgraph-api/tsconfig.json b/packages/ponder-subgraph-api/tsconfig.json index bc62a48..87f1435 100644 --- a/packages/ponder-subgraph-api/tsconfig.json +++ b/packages/ponder-subgraph-api/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "@namehash/shared-configs/tsconfig.lib.json", + "extends": "@namehash/shared-configs/tsconfig.ponder.json", "include": ["src/**/*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f18e54f..0c6003b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,18 +18,9 @@ catalogs: hono: specifier: ^4.6.14 version: 4.6.17 - ponder: - specifier: ^0.8.26 - version: 0.8.26 typescript: specifier: ^5.7.3 version: 5.7.3 - viem: - specifier: ^2.22.13 - version: 2.22.13 - vitest: - specifier: ^3.0.3 - version: 3.0.3 overrides: vite: 5.4.12 @@ -41,6 +32,9 @@ importers: '@biomejs/biome': specifier: ^1.9.4 version: 1.9.4 + typescript: + specifier: 'catalog:' + version: 5.7.3 apps/ensnode: dependencies: @@ -136,9 +130,6 @@ importers: '@escape.tech/graphql-armor-max-tokens': specifier: ^2.5.0 version: 2.5.0 - change-case: - specifier: ^5.4.4 - version: 5.4.4 dataloader: specifier: ^2.2.3 version: 2.2.3 @@ -857,9 +848,6 @@ packages: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - change-case@5.4.4: - resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -2760,8 +2748,6 @@ snapshots: chalk@5.4.1: {} - change-case@5.4.4: {} - check-error@2.1.1: {} chokidar@3.6.0: