From 768db0064cf1bd5a99d9be44594f4b5d42ddff67 Mon Sep 17 00:00:00 2001 From: juprodh Date: Mon, 27 Jul 2026 13:41:07 +0800 Subject: [PATCH] Fix handle changes returning 404 --- src/firehose/handlers.ts | 26 ++-- src/firehose/index.ts | 91 ++++++++++-- src/lib/profile-sweep.ts | 65 +++++++++ src/server/db/queries/cursor.ts | 16 +++ src/server/db/queries/index.ts | 3 +- src/server/db/queries/profile-cache.ts | 15 +- src/server/db/queries/wiki.ts | 10 ++ tests/firehose/hostile-records.test.ts | 39 +++++ tests/firehose/identity.test.ts | 49 ------- tests/lib/profile-sweep.test.ts | 136 ++++++++++++++++++ tests/server/db/queries/cursor.test.ts | 27 ++++ tests/server/db/queries/profile-cache.test.ts | 35 +++-- 12 files changed, 417 insertions(+), 95 deletions(-) create mode 100644 src/lib/profile-sweep.ts delete mode 100644 tests/firehose/identity.test.ts create mode 100644 tests/lib/profile-sweep.test.ts diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 9b3c1b5..5e2f64c 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -1,4 +1,4 @@ -import { isDid } from "@atcute/lexicons/syntax"; +import { isDid, isRecordKey } from "@atcute/lexicons/syntax"; import { canEdit, getAccessLevel } from "../lib/access.ts"; import { COLLECTIONS, normalizeRole } from "../lib/collections.ts"; import { isValidLanguageTag } from "../lib/languages.ts"; @@ -24,7 +24,6 @@ import { getWikiByAtUri, isDidBanned, setWikiTheme, - updateCachedHandle, upsertBookmark, upsertMembership, upsertNote, @@ -263,6 +262,21 @@ export function handleCommitEvent(evt: FirehoseCommit): void { const atUri = `at://${evt.did}/${evt.collection}/${evt.rkey}`; + // These two were @atcute/jetstream's job until we turned validateEvents off (see + // src/firehose/index.ts), and the backfill path has always had to do them itself. + // Both feed sinks that assume the atproto charset: `did` reaches authorization + // and the member directory, `rkey` becomes a wiki slug in an href. Doing them + // here rather than per-path is what makes this function the chokepoint that + // INGESTION-AUDIT.md §2 asks for. + if (!isDid(evt.did)) { + logDrop(atUri, "invalid did"); + return; + } + if (!isRecordKey(evt.rkey)) { + logDrop(atUri, "invalid rkey"); + return; + } + if (evt.operation === "delete") { handleDelete(atUri, evt.collection); return; @@ -328,14 +342,6 @@ export function handleCommitEvent(evt: FirehoseCommit): void { } } -// Listing queries read profile_cache with no freshness predicate, so a stale -// handle is self-reinforcing: the page that would refresh it sits behind the -// dead link it produces. -export function handleIdentityEvent(did: string, handle: string): void { - if (isDidBanned(did)) return; - updateCachedHandle(did, handle); -} - // --- Authorization --- // // Jetstream commits are collection-filtered, not repo-filtered: any actor can diff --git a/src/firehose/index.ts b/src/firehose/index.ts index f4480d0..27c6ad0 100644 --- a/src/firehose/index.ts +++ b/src/firehose/index.ts @@ -2,17 +2,17 @@ import { JetstreamSubscription } from "@atcute/jetstream"; import { getJetstreamUrl, isAuthEnabled } from "../atproto/env.ts"; import { COLLECTIONS } from "../lib/collections.ts"; import { resolveProfile } from "../lib/profile.ts"; +import { + refreshTrackedProfile, + sweepOwnerHandles, +} from "../lib/profile-sweep.ts"; import { getCursor, hasCachedProfile, isDidBanned, setCursor, } from "../server/db/queries/index.ts"; -import { - type FirehoseCommit, - handleCommitEvent, - handleIdentityEvent, -} from "./handlers.ts"; +import { type FirehoseCommit, handleCommitEvent } from "./handlers.ts"; const jetstreamUrl = getJetstreamUrl(); // No cursor in dev: there are no real network users, only events past jetstream's retention. @@ -24,9 +24,21 @@ if (savedCursor) { console.log(`Resuming from cursor ${savedCursor}`); } +// Jetstream omits `handle` from identity events, but @atcute/jetstream's schema +// marks it required, so with validation on every identity event on the network is +// rejected and dropped before it reaches us — silently, since the library's only +// onError call site is that same branch. That killed handle refresh entirely. +// +// Turning validation off means we inherit the two checks it was doing that we +// actually depend on: `did` and `rkey`, both now enforced in handleCommitEvent, and +// `time_us`, which we can only partly cover (see setCursor). Everything else the +// schema checked we either re-derive or never read. +// +// Revisit when @atcute/jetstream makes identity.handle optional. const subscription = new JetstreamSubscription({ url: jetstreamUrl, wantedCollections: Object.values(COLLECTIONS), + validateEvents: false, ...(savedCursor != null && { cursor: savedCursor }), }); @@ -37,6 +49,25 @@ const cursorInterval = setInterval(() => { } }, 5_000); +// Identity events are the fast path for a rename, but they depend on Jetstream +// sending them and on us being connected when they do. This is the backstop for +// both: everything missed while the subscriber was down past jetstream's retention +// converges here instead of staying wrong indefinitely. +const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000; + +function sweepHandles(): void { + void sweepOwnerHandles() + .then((count) => { + if (count > 0) console.log(`[firehose] swept ${count} owner profiles`); + }) + .catch((err) => { + console.error("Owner handle sweep failed:", err); + }); +} + +const sweepInterval = setInterval(sweepHandles, SWEEP_INTERVAL_MS); +if (!isDevMode) sweepHandles(); + let shuttingDown = false; // Nothing on a listing path resolves profiles, so an owner who never logged in @@ -49,25 +80,54 @@ function warmProfile(did: string): void { }); } +// Shape check the schema used to do. Without it a commit event missing `commit` +// throws a TypeError out of the for-await, which `run().catch` swallows into a +// stopped subscriber — the loop reads as if it simply ran out of events. +function toFirehoseCommit(event: { + did: string; + commit: { + collection?: unknown; + rkey?: unknown; + operation?: unknown; + record?: unknown; + }; +}): FirehoseCommit | null { + const commit = event.commit; + if (typeof commit !== "object" || commit === null) return null; + + const { collection, rkey, operation } = commit; + if (typeof collection !== "string" || typeof rkey !== "string") return null; + if ( + operation !== "create" && + operation !== "update" && + operation !== "delete" + ) + return null; + + return { + did: event.did, + collection, + rkey, + operation, + record: operation === "delete" ? undefined : commit.record, + }; +} + async function run(): Promise { for await (const event of subscription) { // wantedCollections filters commits only; identity events arrive regardless. + // Jetstream sends no handle with them, so this is an invalidation signal — + // the value has to come from a resolve. if (event.kind === "identity") { - handleIdentityEvent(event.identity.did, event.identity.handle); + refreshTrackedProfile(event.did); continue; } if (event.kind !== "commit") continue; - if (event.commit.collection === COLLECTIONS.wiki) warmProfile(event.did); + const commit = toFirehoseCommit(event); + if (commit === null) continue; - const commit: FirehoseCommit = { - did: event.did, - collection: event.commit.collection, - rkey: event.commit.rkey, - operation: event.commit.operation, - record: - event.commit.operation !== "delete" ? event.commit.record : undefined, - }; + if (commit.collection === COLLECTIONS.wiki) warmProfile(commit.did); try { handleCommitEvent(commit); @@ -90,6 +150,7 @@ function shutdown(): void { shuttingDown = true; console.log("Shutting down jetstream..."); clearInterval(cursorInterval); + clearInterval(sweepInterval); if (!isDevMode && subscription.cursor != null) { setCursor(subscription.cursor); } diff --git a/src/lib/profile-sweep.ts b/src/lib/profile-sweep.ts new file mode 100644 index 0000000..395e8a7 --- /dev/null +++ b/src/lib/profile-sweep.ts @@ -0,0 +1,65 @@ +import { + expireCachedProfile, + hasCachedProfile, + isDidBanned, + listWikiOwnerDids, +} from "../server/db/queries/index.ts"; +import { resolveProfile } from "./profile.ts"; + +// Injectable so tests exercise the sweep, not the network. resolveProfile's own +// `fetchFn` hook is not usable here: every setCachedProfile call inside it is +// guarded on `fetchFn === fetch`, so injecting one resolves without ever writing. +export type ProfileResolver = (did: string) => Promise; + +// Two at a time: this is maintenance, and each resolve is two upstream requests +// (plc.directory, then the Bluesky appview). No reason to burst 45 of them. +const SWEEP_CONCURRENCY = 2; + +// Listing queries read profile_cache with no freshness predicate, and nothing on a +// listing path resolves. So a renamed owner's handle goes stale and stays stale — +// the profile page that would refresh it sits behind the dead link it produces. +// This is the only thing that reliably breaks that loop; identity events are the +// fast path, not the guarantee. +// +// No forced expiry here: resolveProfile short-circuits inside profileCacheHours, +// so the TTL decides what actually costs a request. A sweep of 45 owners where +// none are stale makes zero network calls. +export async function sweepOwnerHandles( + resolve: ProfileResolver = resolveProfile, +): Promise { + const dids = listWikiOwnerDids().filter((did) => !isDidBanned(did)); + let swept = 0; + + for (let i = 0; i < dids.length; i += SWEEP_CONCURRENCY) { + const batch = dids.slice(i, i + SWEEP_CONCURRENCY); + await Promise.all( + batch.map(async (did) => { + try { + await resolve(did); + swept++; + } catch { + // resolveProfile logs and caches its own failures; one bad owner + // must not abandon the rest of the sweep. + } + }), + ); + } + + return swept; +} + +// An identity event means the handle changed now, so the TTL has to be bypassed — +// otherwise this is a no-op until the row ages out on its own. +// +// UPDATE-only by way of hasCachedProfile: identity events arrive for every DID on +// the network, not just ours, so an unknown DID must cost nothing beyond the lookup. +export function refreshTrackedProfile( + did: string, + resolve: ProfileResolver = resolveProfile, +): void { + if (isDidBanned(did) || !hasCachedProfile(did)) return; + expireCachedProfile(did); + void resolve(did).catch(() => { + // Already logged in resolveProfile; ingestion must not stop for one profile. + }); +} diff --git a/src/server/db/queries/cursor.ts b/src/server/db/queries/cursor.ts index ec2d806..6883d3f 100644 --- a/src/server/db/queries/cursor.ts +++ b/src/server/db/queries/cursor.ts @@ -8,7 +8,23 @@ export function getCursor(): number | null { return row?.cursor ?? null; } +// The subscription assigns `time_us` to its cursor before we ever see the event, +// and with validateEvents off nothing upstream has checked it. A bogus value there +// would skip the firehose forward with no way back — persisting it would make that +// permanent. We cannot stop the in-memory jump, but refusing to store it means a +// restart resumes from the last sane position. +const CURSOR_SKEW_TOLERANCE_US = 3_600_000_000; + +function isPlausibleCursor(cursor: number): boolean { + if (!Number.isSafeInteger(cursor) || cursor <= 0) return false; + return cursor <= Date.now() * 1000 + CURSOR_SKEW_TOLERANCE_US; +} + export function setCursor(cursor: number): void { + if (!isPlausibleCursor(cursor)) { + console.warn(`[firehose] refusing implausible cursor ${cursor}`); + return; + } const db = getDb(); db.run( `INSERT INTO firehose_cursor (id, cursor, updated_at) diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 049f129..54bfee0 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -55,11 +55,11 @@ export { upsertNote, } from "./note.ts"; export { + expireCachedProfile, getCachedProfile, getCachedProfilesByDids, hasCachedProfile, setCachedProfile, - updateCachedHandle, } from "./profile-cache.ts"; export { applyRevisionFromFirehose, @@ -87,6 +87,7 @@ export { listPublicNotesForSitemap, listPublicWikisForSitemap, listPublicWikisPaginated, + listWikiOwnerDids, setWikiHomeSlug, setWikiSidebar, setWikiTheme, diff --git a/src/server/db/queries/profile-cache.ts b/src/server/db/queries/profile-cache.ts index da93352..a84dfd7 100644 --- a/src/server/db/queries/profile-cache.ts +++ b/src/server/db/queries/profile-cache.ts @@ -79,10 +79,15 @@ export function setCachedProfile( ); } -// Handle-only update from an identity event. UPDATE, never INSERT: those events -// cover every DID on the network, and we only track ours. updated_at is left -// alone so the display_name/avatar TTL keeps running. -export function updateCachedHandle(did: string, handle: string): void { +// Force the next resolveProfile past its TTL. An identity event says the handle +// changed *now*, but resolveProfile short-circuits on any row inside +// profileCacheHours, so without this the refresh is a no-op for 24h. +// UPDATE, never INSERT: identity events cover every DID on the network, and we +// only track ours. +export function expireCachedProfile(did: string): void { const db = getDb(); - db.run("UPDATE profile_cache SET handle = ? WHERE did = ?", [handle, did]); + db.run( + "UPDATE profile_cache SET updated_at = datetime('now', '-100 years') WHERE did = ?", + [did], + ); } diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index e479e2d..cb222e7 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -142,6 +142,16 @@ export function getWiki(did: string, slug: string): WikiRow | null { ); } +// Every DID whose handle can appear in a listing link (`owner_ref`). Deliberately +// not filtered on visibility: a private wiki's owner still renders on their profile. +export function listWikiOwnerDids(): string[] { + const db = getDb(); + const rows = db.query("SELECT DISTINCT did FROM wikis").all() as { + did: string; + }[]; + return rows.map((row) => row.did); +} + export function listOwnedWikis( did: string, options: { publicOnly?: boolean } = {}, diff --git a/tests/firehose/hostile-records.test.ts b/tests/firehose/hostile-records.test.ts index b4160ed..4d20f20 100644 --- a/tests/firehose/hostile-records.test.ts +++ b/tests/firehose/hostile-records.test.ts @@ -4,6 +4,7 @@ import { COLLECTIONS } from "../../src/lib/collections.ts"; import { getCurrentNote, getNoteBySlug, + getWiki, listMembers, upsertWiki, } from "../../src/server/db/queries/index.ts"; @@ -41,12 +42,50 @@ function membershipCommit(rkey: string, memberDid: string) { }; } +const CONTROL_SLUG = "envelope-control"; + +function wikiCommit(rkey: string, did: string = OWNER) { + return { + did, + collection: COLLECTIONS.wiki, + rkey, + operation: "create" as const, + record: { name: "Envelope", visibility: "public", createdAt: ISO }, + }; +} + beforeAll(() => { upsertWiki(SLUG, OWNER, "Hostile Wiki", "public", WIKI_URI, ISO); }); afterAll(() => { cleanupWikiAndDependents(SLUG); + cleanupWikiAndDependents(CONTROL_SLUG); +}); + +// `did` and `rkey` used to be validated by @atcute/jetstream's schema. We turned +// validateEvents off to stop it silently dropping identity events, which makes +// these checks ours — and the rkey one is the door INGESTION-AUDIT.md §4 left open +// on purpose while the library was still holding it shut. +describe("commit envelope shape", () => { + test("drops an rkey that breaks out of an attribute", () => { + const rkey = `x" onmouseover="alert(1)`; + handleCommitEvent(wikiCommit(rkey)); + expect(getWiki(OWNER, rkey)).toBeNull(); + }); + + test("drops a did that is not a did", () => { + const did = `did:plc:x" onmouseover="y`; + handleCommitEvent(wikiCommit(CONTROL_SLUG, did)); + expect(getWiki(did, CONTROL_SLUG)).toBeNull(); + }); + + // Without this the two above would pass just as well if ingestion were broken + // outright. + test("still ingests a well-formed envelope", () => { + handleCommitEvent(wikiCommit(CONTROL_SLUG)); + expect(getWiki(OWNER, CONTROL_SLUG)).not.toBeNull(); + }); }); describe("note.slug shape", () => { diff --git a/tests/firehose/identity.test.ts b/tests/firehose/identity.test.ts deleted file mode 100644 index 0ef6ad3..0000000 --- a/tests/firehose/identity.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { handleIdentityEvent } from "../../src/firehose/handlers.ts"; -import { getDb } from "../../src/server/db/index.ts"; -import { - banDid, - getCachedProfile, - hasCachedProfile, - setCachedProfile, - unbanDid, -} from "../../src/server/db/queries/index.ts"; - -const db = getDb(); - -const TRACKED_DID = "did:plc:identitytracked"; -const STRANGER_DID = "did:plc:identitystranger"; -const BANNED_DID = "did:plc:identitybanned"; -const DIDS = [TRACKED_DID, STRANGER_DID, BANNED_DID]; - -afterAll(() => { - unbanDid(BANNED_DID); - for (const did of DIDS) { - db.run("DELETE FROM profile_cache WHERE did = ?", [did]); - } -}); - -describe("handleIdentityEvent", () => { - test("refreshes the cached handle of a DID we track", () => { - setCachedProfile(TRACKED_DID, "before.test", "Tracked", null); - - handleIdentityEvent(TRACKED_DID, "after.test"); - - expect(getCachedProfile(TRACKED_DID)?.handle).toBe("after.test"); - }); - - test("ignores DIDs we have never cached", () => { - handleIdentityEvent(STRANGER_DID, "stranger.test"); - - expect(hasCachedProfile(STRANGER_DID)).toBe(false); - }); - - test("ignores banned DIDs", () => { - setCachedProfile(BANNED_DID, "banned.test", null, null); - banDid(BANNED_DID, "abuse", "tester"); - - handleIdentityEvent(BANNED_DID, "renamed.test"); - - expect(getCachedProfile(BANNED_DID)?.handle).toBe("banned.test"); - }); -}); diff --git a/tests/lib/profile-sweep.test.ts b/tests/lib/profile-sweep.test.ts new file mode 100644 index 0000000..e84d759 --- /dev/null +++ b/tests/lib/profile-sweep.test.ts @@ -0,0 +1,136 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + refreshTrackedProfile, + sweepOwnerHandles, +} from "../../src/lib/profile-sweep.ts"; +import { getDb } from "../../src/server/db/index.ts"; +import { + banDid, + getCachedProfile, + hasCachedProfile, + setCachedProfile, + unbanDid, + upsertWiki, +} from "../../src/server/db/queries/index.ts"; + +const db = getDb(); + +const TRACKED_DID = "did:plc:sweeptracked"; +const STRANGER_DID = "did:plc:sweepstranger"; +const BANNED_DID = "did:plc:sweepbanned"; +const TWO_WIKI_DID = "did:plc:sweeptwowikis"; +const FAILING_DID = "did:plc:sweepfailing"; + +const OWNER_DIDS = [TRACKED_DID, BANNED_DID, TWO_WIKI_DID, FAILING_DID]; +const ALL_DIDS = [...OWNER_DIDS, STRANGER_DID]; + +function addWiki(did: string, slug: string): void { + upsertWiki( + slug, + did, + slug, + "public", + `at://${did}/wiki.lichen.wiki/${slug}`, + new Date().toISOString(), + ); +} + +// Collects what the sweep asked for instead of hitting the network. resolveProfile's +// own fetchFn hook is unusable here — it disables the cache writes too. +function recordingResolver(): { + calls: string[]; + resolve: (did: string) => Promise; +} { + const calls: string[] = []; + return { + calls, + resolve: async (did: string) => { + calls.push(did); + return {}; + }, + }; +} + +afterAll(() => { + unbanDid(BANNED_DID); + for (const did of ALL_DIDS) { + db.run("DELETE FROM profile_cache WHERE did = ?", [did]); + db.run("DELETE FROM wikis WHERE did = ?", [did]); + } +}); + +describe("sweepOwnerHandles", () => { + test("resolves each wiki owner once, however many wikis they own", async () => { + addWiki(TWO_WIKI_DID, "sweep-alpha"); + addWiki(TWO_WIKI_DID, "sweep-beta"); + const { calls, resolve } = recordingResolver(); + + await sweepOwnerHandles(resolve); + + expect(calls.filter((did) => did === TWO_WIKI_DID)).toHaveLength(1); + }); + + test("skips banned owners", async () => { + addWiki(BANNED_DID, "sweep-banned"); + banDid(BANNED_DID, "abuse", "tester"); + const { calls, resolve } = recordingResolver(); + + await sweepOwnerHandles(resolve); + + expect(calls).not.toContain(BANNED_DID); + }); + + // The sweep is a maintenance loop over every owner; one unreachable PDS must not + // cost the rest of the batch. + test("continues past an owner that fails to resolve", async () => { + addWiki(FAILING_DID, "sweep-failing"); + addWiki(TRACKED_DID, "sweep-ok"); + const calls: string[] = []; + const resolve = async (did: string): Promise => { + calls.push(did); + if (did === FAILING_DID) throw new Error("plc unreachable"); + return {}; + }; + + const swept = await sweepOwnerHandles(resolve); + + expect(calls).toContain(TRACKED_DID); + expect(swept).toBe(calls.length - 1); + }); +}); + +describe("refreshTrackedProfile", () => { + // The expiry is the whole point: resolveProfile short-circuits inside the TTL, so + // without it the refresh silently does nothing for 24h. Asserting only that the + // resolver ran would pass with the expiry removed. + test("invalidates the cached row so the resolve is not short-circuited", () => { + setCachedProfile(TRACKED_DID, "before.test", "Tracked", null); + const { calls, resolve } = recordingResolver(); + + refreshTrackedProfile(TRACKED_DID, resolve); + + expect(calls).toEqual([TRACKED_DID]); + expect(getCachedProfile(TRACKED_DID)).toBeNull(); + }); + + // Identity events arrive for every DID on the network, not just ours. + test("ignores DIDs we have never cached", () => { + const { calls, resolve } = recordingResolver(); + + refreshTrackedProfile(STRANGER_DID, resolve); + + expect(calls).toEqual([]); + expect(hasCachedProfile(STRANGER_DID)).toBe(false); + }); + + test("ignores banned DIDs", () => { + setCachedProfile(BANNED_DID, "banned.test", null, null); + banDid(BANNED_DID, "abuse", "tester"); + const { calls, resolve } = recordingResolver(); + + refreshTrackedProfile(BANNED_DID, resolve); + + expect(calls).toEqual([]); + expect(getCachedProfile(BANNED_DID)?.handle).toBe("banned.test"); + }); +}); diff --git a/tests/server/db/queries/cursor.test.ts b/tests/server/db/queries/cursor.test.ts index 64e8e9c..e289436 100644 --- a/tests/server/db/queries/cursor.test.ts +++ b/tests/server/db/queries/cursor.test.ts @@ -28,3 +28,30 @@ describe("firehose cursor", () => { expect(getCursor()).toBe(200); }); }); + +// With validateEvents off, time_us reaches the subscription's cursor unchecked, and +// the subscription assigns it before we ever see the event. We cannot stop the +// in-memory jump — refusing to persist it is what keeps the damage to one session +// instead of surviving the restart that would otherwise recover. +describe("setCursor plausibility", () => { + const nowUs = Date.now() * 1000; + + test.each([ + ["far-future", nowUs + 30 * 24 * 60 * 60 * 1_000_000], + ["beyond safe-integer range", Number.MAX_SAFE_INTEGER + 2], + ["not a number", Number.NaN], + ["negative", -1], + ["zero", 0], + ])("keeps the last good cursor when given a %s value", (_label, bad) => { + setCursor(nowUs); + setCursor(bad); + expect(getCursor()).toBe(nowUs); + }); + + // Jetstream's clock is not ours; a cursor slightly ahead is normal operation. + test("allows small clock skew", () => { + const skewed = nowUs + 60 * 1_000_000; + setCursor(skewed); + expect(getCursor()).toBe(skewed); + }); +}); diff --git a/tests/server/db/queries/profile-cache.test.ts b/tests/server/db/queries/profile-cache.test.ts index 77e6fb3..6b94fb4 100644 --- a/tests/server/db/queries/profile-cache.test.ts +++ b/tests/server/db/queries/profile-cache.test.ts @@ -1,10 +1,10 @@ import { afterAll, describe, expect, test } from "bun:test"; import { getDb } from "../../../../src/server/db/index.ts"; import { + expireCachedProfile, getCachedProfile, hasCachedProfile, setCachedProfile, - updateCachedHandle, } from "../../../../src/server/db/queries/index.ts"; const db = getDb(); @@ -57,26 +57,31 @@ describe("getCachedProfile freshness", () => { }); }); -describe("updateCachedHandle", () => { - test("rewrites the handle of a tracked DID without clearing the profile", () => { - setCachedProfile( - "did:plc:pcacherename", - "old.test", - "Renamed", - "https://cdn.example.com/a.jpg", - ); - updateCachedHandle("did:plc:pcacherename", "new.test"); +describe("expireCachedProfile", () => { + // resolveProfile short-circuits on any row inside the TTL, so this is the whole + // mechanism by which an identity event causes a re-resolve rather than a no-op. + test("pushes a fresh row past the TTL", () => { + setCachedProfile("did:plc:pcacherename", "old.test", "Renamed", null); + expect(getCachedProfile("did:plc:pcacherename")).not.toBeNull(); - const row = getCachedProfile("did:plc:pcacherename"); - expect(row?.handle).toBe("new.test"); - expect(row?.display_name).toBe("Renamed"); - expect(row?.avatar).toBe("https://cdn.example.com/a.jpg"); + expireCachedProfile("did:plc:pcacherename"); + + expect(getCachedProfile("did:plc:pcacherename")).toBeNull(); + }); + + // Deleting instead would make hasCachedProfile false, and refreshTrackedProfile + // gates on it — the DID would stop being refreshable at all. + test("keeps the row so the DID still counts as tracked", () => { + setCachedProfile("did:plc:pcacherename", "old.test", "Renamed", null); + expireCachedProfile("did:plc:pcacherename"); + + expect(hasCachedProfile("did:plc:pcacherename")).toBe(true); }); // Inserting on every network-wide identity event would grow the table without // bound for accounts we never serve. test("does not create a row for an untracked DID", () => { - updateCachedHandle("did:plc:pcacheunknown", "stranger.test"); + expireCachedProfile("did:plc:pcacheunknown"); expect(hasCachedProfile("did:plc:pcacheunknown")).toBe(false); }); }); -- 2.51.2