diff --git a/.changeset/brave-waves-flow.md b/.changeset/brave-waves-flow.md new file mode 100644 index 000000000..2649eb7e7 --- /dev/null +++ b/.changeset/brave-waves-flow.md @@ -0,0 +1,6 @@ +--- +"@namehash/ens-referrals": minor +"ensapi": minor +--- + +Added `status` field to referral program API responses (`ReferrerLeaderboardPage`, `ReferrerEditionMetricsRanked`, `ReferrerEditionMetricsUnranked`) indicating whether a program is "Scheduled", "Active", or "Closed" based on the program's timing relative to `accurateAsOf`. diff --git a/.changeset/clever-frogs-detect.md b/.changeset/clever-frogs-detect.md new file mode 100644 index 000000000..09532c05f --- /dev/null +++ b/.changeset/clever-frogs-detect.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +SWRCache `fn` now optionally receives the currently cached result as a parameter, allowing implementations to inspect cached data before deciding whether to return it or fetch fresh data. Fully backward compatible. diff --git a/.changeset/lemon-moose-count.md b/.changeset/lemon-moose-count.md new file mode 100644 index 000000000..7b432a31a --- /dev/null +++ b/.changeset/lemon-moose-count.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Referral program edition leaderboard caches now check for immutability within the cache builder function. Closed editions past the safety window return cached data without re-fetching. diff --git a/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts b/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts index 2672356a2..2a839bfb4 100644 --- a/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts +++ b/apps/ensapi/src/cache/referral-leaderboard-editions.cache.ts @@ -8,12 +8,14 @@ import { import { minutesToSeconds } from "date-fns"; import { + type CachedResult, getLatestIndexedBlockRef, type OmnichainIndexingStatusId, OmnichainIndexingStatusIds, SWRCache, } from "@ensnode/ensnode-sdk"; +import { assumeReferralProgramEditionImmutablyClosed } from "@/lib/ensanalytics/referrer-leaderboard/closeout"; import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1"; import { makeLogger } from "@/lib/logger"; @@ -48,15 +50,34 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ /** * Creates a cache builder function for a specific edition. * + * The builder function checks if cached data exists and represents an immutably closed edition. + * If so, it returns the cached data without re-fetching. Otherwise, it fetches fresh data. + * * @param editionConfig - The edition configuration * @returns A function that builds the leaderboard for the given edition */ function createEditionLeaderboardBuilder( editionConfig: ReferralProgramEditionConfig, -): () => Promise { - return async (): Promise => { +): (cachedResult?: CachedResult) => Promise { + return async (cachedResult?: CachedResult): Promise => { const editionSlug = editionConfig.slug; + // Check if cached data is immutable and can be returned as-is + if (cachedResult && !(cachedResult.result instanceof Error)) { + const isImmutable = assumeReferralProgramEditionImmutablyClosed( + cachedResult.result.rules, + cachedResult.result.accurateAsOf, + ); + + if (isImmutable) { + logger.debug( + { editionSlug }, + `Edition is immutably closed, returning cached data without re-fetching`, + ); + return cachedResult.result; + } + } + const indexingStatus = await indexingStatusCache.read(); if (indexingStatus instanceof Error) { logger.error( diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index aa72ef716..2bbe12ef0 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -34,6 +34,7 @@ import { deserializeReferrerMetricsEditionsResponse, ReferralProgramEditionConfigSetResponseCodes, type ReferralProgramEditionSlug, + ReferralProgramStatuses, ReferrerEditionMetricsTypeIds, type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, @@ -118,6 +119,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...populatedReferrerLeaderboard, + status: ReferralProgramStatuses.Active, pageContext: { endIndex: 9, hasNext: true, @@ -139,6 +141,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...populatedReferrerLeaderboard, + status: ReferralProgramStatuses.Active, pageContext: { endIndex: 19, hasNext: true, @@ -159,6 +162,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...populatedReferrerLeaderboard, + status: ReferralProgramStatuses.Active, pageContext: { endIndex: 28, hasNext: false, @@ -223,6 +227,7 @@ describe("/v1/ensanalytics", () => { responseCode: ReferrerLeaderboardPageResponseCodes.Ok, data: { ...emptyReferralLeaderboard, + status: ReferralProgramStatuses.Active, pageContext: { hasNext: false, hasPrev: false, @@ -364,6 +369,7 @@ describe("/v1/ensanalytics", () => { referrer: expectedMetrics, aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, accurateAsOf: expectedAccurateAsOf, + status: ReferralProgramStatuses.Active, }, "2026-03": { type: ReferrerEditionMetricsTypeIds.Ranked, @@ -371,6 +377,7 @@ describe("/v1/ensanalytics", () => { referrer: expectedMetrics, aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics, accurateAsOf: expectedAccurateAsOf, + status: ReferralProgramStatuses.Active, }, }, } satisfies ReferrerMetricsEditionsResponseOk; diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/closeout.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/closeout.ts new file mode 100644 index 000000000..e1f0d7333 --- /dev/null +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/closeout.ts @@ -0,0 +1,32 @@ +import type { ReferralProgramRules } from "@namehash/ens-referrals/v1"; +import { minutesToSeconds } from "date-fns"; + +import { addDuration, type Duration, type UnixTimestamp } from "@ensnode/ensnode-sdk"; + +/** + * Duration after which we assume a closed edition is safe from chain reorganizations. + * + * This is a heuristic value (10 minutes) chosen to provide a reasonable safety margin + * beyond typical Ethereum finality. It is not a guarantee of immutability. + */ +export const ASSUMED_CHAIN_REORG_SAFE_DURATION: Duration = minutesToSeconds(10); + +/** + * Assumes a referral program edition is immutably closed if it ended more than + * ASSUMED_CHAIN_REORG_SAFE_DURATION ago. + * + * This is a practical heuristic for determining when edition data can be cached + * indefinitely, based on the assumption that chain reorgs become extremely unlikely + * after the safety window has passed. + * + * @param rules - The referral program rules containing endTime + * @param referenceTime - The timestamp to check against (typically accurateAsOf from cached leaderboard) + * @returns true if we assume the edition is immutably closed + */ +export function assumeReferralProgramEditionImmutablyClosed( + rules: ReferralProgramRules, + referenceTime: UnixTimestamp, +): boolean { + const immutabilityThreshold = addDuration(rules.endTime, ASSUMED_CHAIN_REORG_SAFE_DURATION); + return referenceTime > immutabilityThreshold; +} diff --git a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts index 7ea8bf34c..ac90c8aab 100644 --- a/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts +++ b/apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts @@ -1,4 +1,5 @@ import { + ReferralProgramStatuses, type ReferrerLeaderboard, ReferrerLeaderboardPageResponseCodes, type ReferrerLeaderboardPageResponseOk, @@ -1093,6 +1094,7 @@ export const referrerLeaderboardPageResponseOk: ReferrerLeaderboardPageResponseO startIndex: 0, endIndex: 28, }, + status: ReferralProgramStatuses.Active, accurateAsOf: 1735689600, }, }; diff --git a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts index 1546b76e0..34f713543 100644 --- a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts +++ b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts @@ -36,6 +36,9 @@ export type ReferralLeaderboardEditionsCachesMiddlewareVariables = { * This middleware depends on {@link referralProgramEditionConfigSetMiddleware} to provide * the edition config set. If the edition config set failed to load, this middleware propagates the error. * Otherwise, it initializes caches for each edition in the config set. + * + * Each cache's builder function handles immutability internally - when an edition becomes immutably + * closed (past the safety window), the builder returns cached data without re-fetching. */ export const referralLeaderboardEditionsCachesMiddleware = factory.createMiddleware( async (c, next) => { diff --git a/packages/ens-referrals/src/v1/api/serialize.ts b/packages/ens-referrals/src/v1/api/serialize.ts index 371c63ba1..7ab98e429 100644 --- a/packages/ens-referrals/src/v1/api/serialize.ts +++ b/packages/ens-referrals/src/v1/api/serialize.ts @@ -118,6 +118,7 @@ function serializeReferrerLeaderboardPage( referrers: page.referrers.map(serializeAwardedReferrerMetrics), aggregatedMetrics: serializeAggregatedReferrerMetrics(page.aggregatedMetrics), pageContext: page.pageContext, + status: page.status, accurateAsOf: page.accurateAsOf, }; } @@ -133,6 +134,7 @@ function serializeReferrerEditionMetricsRanked( rules: serializeReferralProgramRules(detail.rules), referrer: serializeAwardedReferrerMetrics(detail.referrer), aggregatedMetrics: serializeAggregatedReferrerMetrics(detail.aggregatedMetrics), + status: detail.status, accurateAsOf: detail.accurateAsOf, }; } @@ -148,6 +150,7 @@ function serializeReferrerEditionMetricsUnranked( rules: serializeReferralProgramRules(detail.rules), referrer: serializeUnrankedReferrerMetrics(detail.referrer), aggregatedMetrics: serializeAggregatedReferrerMetrics(detail.aggregatedMetrics), + status: detail.status, accurateAsOf: detail.accurateAsOf, }; } diff --git a/packages/ens-referrals/src/v1/api/zod-schemas.ts b/packages/ens-referrals/src/v1/api/zod-schemas.ts index 371f3060a..2ebe1516e 100644 --- a/packages/ens-referrals/src/v1/api/zod-schemas.ts +++ b/packages/ens-referrals/src/v1/api/zod-schemas.ts @@ -28,6 +28,7 @@ import { ReferrerEditionMetricsTypeIds, } from "../edition-metrics"; import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "../leaderboard-page"; +import { ReferralProgramStatuses } from "../status"; import { MAX_EDITIONS_PER_REQUEST, ReferralProgramEditionConfigSetResponseCodes, @@ -140,6 +141,15 @@ export const makeReferrerLeaderboardPageContextSchema = ( endIndex: z.optional(makeNonNegativeIntegerSchema(`${valueLabel}.endIndex`)), }); +/** + * Schema for referral program status field. + * Validates that the status is one of: "Scheduled", "Active", or "Closed". + */ +export const makeReferralProgramStatusSchema = (valueLabel: string = "status") => + z.enum(ReferralProgramStatuses, { + message: `${valueLabel} must be "Scheduled", "Active", or "Closed"`, + }); + /** * Schema for ReferrerLeaderboardPage */ @@ -149,6 +159,7 @@ export const makeReferrerLeaderboardPageSchema = (valueLabel: string = "Referrer referrers: z.array(makeAwardedReferrerMetricsSchema(`${valueLabel}.referrers[record]`)), aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), pageContext: makeReferrerLeaderboardPageContextSchema(`${valueLabel}.pageContext`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), }); @@ -197,6 +208,7 @@ export const makeReferrerEditionMetricsRankedSchema = ( rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), referrer: makeAwardedReferrerMetricsSchema(`${valueLabel}.referrer`), aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), }); @@ -211,6 +223,7 @@ export const makeReferrerEditionMetricsUnrankedSchema = ( rules: makeReferralProgramRulesSchema(`${valueLabel}.rules`), referrer: makeUnrankedReferrerMetricsSchema(`${valueLabel}.referrer`), aggregatedMetrics: makeAggregatedReferrerMetricsSchema(`${valueLabel}.aggregatedMetrics`), + status: makeReferralProgramStatusSchema(`${valueLabel}.status`), accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), }); diff --git a/packages/ens-referrals/src/v1/edition-metrics.ts b/packages/ens-referrals/src/v1/edition-metrics.ts index 1c46f9a37..f1e54e0cb 100644 --- a/packages/ens-referrals/src/v1/edition-metrics.ts +++ b/packages/ens-referrals/src/v1/edition-metrics.ts @@ -10,6 +10,7 @@ import { type UnrankedReferrerMetrics, } from "./referrer-metrics"; import type { ReferralProgramRules } from "./rules"; +import { calcReferralProgramStatus, type ReferralProgramStatusId } from "./status"; /** * The type of referrer edition metrics data. @@ -66,6 +67,12 @@ export interface ReferrerEditionMetricsRanked { */ aggregatedMetrics: AggregatedReferrerMetrics; + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsRanked} was accurate as of. */ @@ -105,6 +112,12 @@ export interface ReferrerEditionMetricsUnranked { */ aggregatedMetrics: AggregatedReferrerMetrics; + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerEditionMetricsUnranked} was accurate as of. */ @@ -134,6 +147,7 @@ export const getReferrerEditionMetrics = ( leaderboard: ReferrerLeaderboard, ): ReferrerEditionMetrics => { const awardedReferrerMetrics = leaderboard.referrers.get(referrer); + const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); // If referrer is on the leaderboard, return their ranked metrics if (awardedReferrerMetrics) { @@ -142,6 +156,7 @@ export const getReferrerEditionMetrics = ( rules: leaderboard.rules, referrer: awardedReferrerMetrics, aggregatedMetrics: leaderboard.aggregatedMetrics, + status, accurateAsOf: leaderboard.accurateAsOf, }; } @@ -152,6 +167,7 @@ export const getReferrerEditionMetrics = ( rules: leaderboard.rules, referrer: buildUnrankedReferrerMetrics(referrer), aggregatedMetrics: leaderboard.aggregatedMetrics, + status, accurateAsOf: leaderboard.accurateAsOf, }; }; diff --git a/packages/ens-referrals/src/v1/leaderboard-page.ts b/packages/ens-referrals/src/v1/leaderboard-page.ts index b789e455d..72d5a75ff 100644 --- a/packages/ens-referrals/src/v1/leaderboard-page.ts +++ b/packages/ens-referrals/src/v1/leaderboard-page.ts @@ -5,6 +5,7 @@ import type { ReferrerLeaderboard } from "./leaderboard"; import { isNonNegativeInteger, isPositiveInteger } from "./number"; import type { AwardedReferrerMetrics } from "./referrer-metrics"; import type { ReferralProgramRules } from "./rules"; +import { calcReferralProgramStatus, type ReferralProgramStatusId } from "./status"; /** * The default number of referrers per leaderboard page. @@ -287,6 +288,12 @@ export interface ReferrerLeaderboardPage { */ pageContext: ReferrerLeaderboardPageContext; + /** + * The status of the referral program ("Scheduled", "Active", or "Closed") + * calculated based on the program's timing relative to {@link accurateAsOf}. + */ + status: ReferralProgramStatusId; + /** * The {@link UnixTimestamp} of when the data used to build the {@link ReferrerLeaderboardPage} was accurate as of. */ @@ -315,11 +322,14 @@ export const getReferrerLeaderboardPage = ( referrers = []; } + const status = calcReferralProgramStatus(leaderboard.rules, leaderboard.accurateAsOf); + return { rules: leaderboard.rules, referrers, aggregatedMetrics: leaderboard.aggregatedMetrics, pageContext, accurateAsOf: leaderboard.accurateAsOf, + status, }; }; diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts index 0e28a1b9e..3545ee8ee 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts @@ -740,4 +740,167 @@ describe("SWRCache", () => { expect(result2).toBe("success"); }); }); + + describe("immutability support via cached result parameter", () => { + it("should pass undefined on first call when no cache exists", async () => { + const fn = vi.fn(async (cachedResult) => { + return cachedResult ? "has-cache" : "no-cache"; + }); + + const cache = new SWRCache({ fn, ttl: 60 }); + + const result = await cache.read(); + expect(result).toBe("no-cache"); + expect(fn).toHaveBeenCalledWith(undefined); + cache.destroy(); + }); + + it("should pass cached result on subsequent revalidations", async () => { + let callCount = 0; + const fn = vi.fn(async (cachedResult) => { + callCount++; + if (cachedResult && !(cachedResult.result instanceof Error)) { + return `call-${callCount}-saw-${cachedResult.result}`; + } + return `call-${callCount}-fresh`; + }); + + const cache = new SWRCache({ fn, ttl: 1, proactivelyInitialize: true }); + + // Wait for initial load + await vi.runAllTimersAsync(); + const result1 = await cache.read(); + expect(result1).toBe("call-1-fresh"); + + // Advance past TTL to trigger revalidation + vi.advanceTimersByTime(2000); + await cache.read(); + await vi.runAllTimersAsync(); + + const result2 = await cache.read(); + expect(result2).toBe("call-2-saw-call-1-fresh"); + expect(fn).toHaveBeenCalledTimes(2); + + cache.destroy(); + }); + + it("should allow fn to return cached data when immutable", async () => { + let callCount = 0; + + // Simulates an edition leaderboard that becomes immutable + const fn = vi.fn(async (cachedResult) => { + callCount++; + + // If cached data is marked immutable, return it without re-fetching + if ( + cachedResult && + !(cachedResult.result instanceof Error) && + cachedResult.result.isImmutable + ) { + return cachedResult.result; + } + + // Otherwise fetch fresh data + // Becomes immutable on second call + return { + value: `data-${callCount}`, + isImmutable: callCount >= 2, + }; + }); + + const cache = new SWRCache({ fn, ttl: 1, proactivelyInitialize: true }); + + // Initial load + await vi.runAllTimersAsync(); + const result1 = await cache.read(); + expect(result1).toEqual({ value: "data-1", isImmutable: false }); + expect(callCount).toBe(1); + + // Trigger revalidation by advancing past TTL + vi.advanceTimersByTime(2000); + await cache.read(); + await vi.runAllTimersAsync(); + + const result2 = await cache.read(); + expect(result2).toEqual({ value: "data-2", isImmutable: true }); + expect(callCount).toBe(2); + + // Trigger another revalidation + vi.advanceTimersByTime(2000); + await cache.read(); + await vi.runAllTimersAsync(); + + const result3 = await cache.read(); + // Should still be data-2 because fn returned cached immutable data + expect(result3).toEqual({ value: "data-2", isImmutable: true }); + expect(callCount).toBe(3); // fn was called, but returned cached data + + cache.destroy(); + }); + + it("should support backward compatibility with fn that ignores cached result", async () => { + let callCount = 0; + + // Old-style function that doesn't use the cachedResult parameter + const fn = vi.fn(async () => { + callCount++; + return `result-${callCount}`; + }); + + const cache = new SWRCache({ fn, ttl: 1, proactivelyInitialize: true }); + + // Initial load + await vi.runAllTimersAsync(); + const result1 = await cache.read(); + expect(result1).toBe("result-1"); + + // Trigger revalidation + vi.advanceTimersByTime(2000); + await cache.read(); + await vi.runAllTimersAsync(); + + const result2 = await cache.read(); + expect(result2).toBe("result-2"); + expect(callCount).toBe(2); + + cache.destroy(); + }); + + it("should pass cached error result to fn and eventually return success", async () => { + let shouldError = true; + const fn = vi.fn(async (cachedResult) => { + if (cachedResult && cachedResult.result instanceof Error) { + return "recovered-from-error"; + } + if (shouldError) throw new Error("test error"); + return "success"; + }); + + const cache = new SWRCache({ fn, ttl: 1, errorTtl: 1, proactivelyInitialize: true }); + + // Initial load (error) + await vi.runAllTimersAsync(); + const result1 = await cache.read(); + expect(result1).toBeInstanceOf(Error); + + // Trigger revalidation after errorTtl + shouldError = false; + vi.advanceTimersByTime(2000); + await cache.read(); + await vi.runAllTimersAsync(); + + const result2 = await cache.read(); + expect(result2).toBe("recovered-from-error"); + + // Trigger another revalidation - now cached result is not an error + vi.advanceTimersByTime(2000); + await cache.read(); + await vi.runAllTimersAsync(); + + const result3 = await cache.read(); + expect(result3).toBe("success"); + + cache.destroy(); + }); + }); }); diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts index 39022f336..30f1d94d1 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts @@ -7,7 +7,7 @@ import type { Duration, UnixTimestamp } from "../types"; /** * Data structure for a single cached result. */ -interface CachedResult { +export interface CachedResult { /** * The cached result of the fn, either its ValueType or Error. */ @@ -23,8 +23,12 @@ export interface SWRCacheOptions { /** * The async function generating a value of `ValueType` to wrap with SWR caching. It may throw an * Error type. + * + * The function optionally receives the currently cached result (or undefined if no value is + * cached yet). This allows the function to implement custom caching strategies, such as + * returning the same data for immutable values without re-fetching. */ - fn: () => Promise; + fn: (cachedResult?: CachedResult) => Promise; /** * Time-to-live duration of a cached result in seconds. After this duration: @@ -114,7 +118,7 @@ export class SWRCache { // ensure that there is exactly one in progress revalidation promise if (!this.inProgressRevalidate) { this.inProgressRevalidate = this.options - .fn() + .fn(this.cache ?? undefined) .then((result) => { // on success, always update the cache with the latest revalidation this.cache = {