···11+---
22+"@atmo-dev/contrail-base": patch
33+---
44+55+fix(identity): stop stranding/clobbering handles during resolution (#42)
66+77+Backfill left a meaningful fraction of identities with a PDS but no handle.
88+Two root causes:
99+1010+- `resolvePDSCached` short-circuited on any row with a non-null PDS and returned
1111+ without ever resolving the handle. A partial resolution (slingshot can return
1212+ a PDS without a handle under load) was therefore persisted and never healed.
1313+ It now treats a row as a complete cache hit only when both PDS *and* handle are
1414+ present; a PDS-only row falls through to re-resolve and fill the handle, while
1515+ still serving the known PDS (including if the re-resolution fails).
1616+- `saveIdentity` overwrote `handle`/`pds` unconditionally, so
1717+ `refreshStaleIdentities` (which passes a null handle through when slingshot
1818+ omits one) could clobber a previously-resolved handle with null. The upsert now
1919+ COALESCEs both columns: a fresh non-null value still applies (handle changes
2020+ work), but a null never nulls a good value.
2121+2222+Backfill also resolves PDS endpoints up front instead of in a detached
2323+background promise, so identity resolution no longer competes with record
2424+backfill for slingshot — reducing the partial responses that triggered the
2525+above in the first place.
+14-5
packages/contrail-base/src/client.ts
···196196 db?: Database,
197197 config?: ContrailConfig,
198198): Promise<string | undefined> {
199199+ let knownPds: string | undefined;
199200 if (db) {
200201 const cached = await db
201201- .prepare("SELECT pds FROM identities WHERE did = ? AND pds IS NOT NULL")
202202+ .prepare("SELECT pds, handle FROM identities WHERE did = ? AND pds IS NOT NULL")
202203 .bind(did)
203203- .first<{ pds: string }>();
204204+ .first<{ pds: string; handle: string | null }>();
204205 if (cached?.pds) {
205206 pdsCacheSet(did, cached.pds);
206206- return cached.pds;
207207+ // A row with both a PDS and a handle is a complete cache hit. A row with
208208+ // a PDS but no handle is a *partial* resolution — slingshot can return a
209209+ // PDS without a handle under load — so fall through to re-resolve and
210210+ // fill the handle instead of stranding the row forever (this DB
211211+ // short-circuit previously meant the handle was never backfilled). We
212212+ // keep serving the known PDS meanwhile, including if the re-resolve fails.
213213+ if (cached.handle) return cached.pds;
214214+ knownPds = cached.pds;
207215 }
208216 }
209217210218 const resolved = await resolvePDS(did, config);
211211- if (!resolved?.pds) return undefined;
219219+ if (!resolved?.pds) return knownPds;
212220213221 pdsCacheSet(did, resolved.pds);
214222215215- // Persist to DB for future runs
223223+ // Persist to DB for future runs. COALESCE keeps an existing handle when this
224224+ // resolution didn't return one, and never nulls a good handle.
216225 if (db) {
217226 await db
218227 .prepare(
+5-1
packages/contrail-base/src/identity.ts
···1515async function saveIdentity(db: Database, identity: Identity): Promise<void> {
1616 await db
1717 .prepare(
1818- "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, pds = excluded.pds, resolved_at = excluded.resolved_at"
1818+ // COALESCE so a null handle/pds from a partial resolution never clobbers
1919+ // a previously-resolved value (e.g. refreshStaleIdentities passes through
2020+ // a null handle when slingshot omits it). A fresh non-null value still
2121+ // overwrites — handle changes apply normally.
2222+ "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = COALESCE(excluded.handle, identities.handle), pds = COALESCE(excluded.pds, identities.pds), resolved_at = excluded.resolved_at"
1923 )
2024 .bind(identity.did, identity.handle, identity.pds, identity.resolved_at)
2125 .run();
+114
packages/contrail/tests/identity-handle.test.ts
···11+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22+import { getPDS, __resetPdsCachesForTests } from "../src/core/client";
33+import { refreshStaleIdentities } from "../src/core/identity";
44+import { createTestDbWithSchema } from "./helpers";
55+import type { Database } from "../src/core/types";
66+import type { Did } from "@atcute/lexicons";
77+88+// Root-cause coverage for PR #42: identities that end up with a PDS but no
99+// handle must not be stranded, and a partial re-resolution must never clobber a
1010+// handle that was already known.
1111+1212+const DID = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" as Did;
1313+const PDS = "https://pds.example.host.bsky.network";
1414+1515+let db: Database;
1616+let fetchSpy: ReturnType<typeof vi.spyOn>;
1717+1818+async function seedIdentity(
1919+ did: string,
2020+ handle: string | null,
2121+ pds: string | null,
2222+ resolvedAt: number
2323+): Promise<void> {
2424+ await db
2525+ .prepare(
2626+ "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)"
2727+ )
2828+ .bind(did, handle, pds, resolvedAt)
2929+ .run();
3030+}
3131+3232+async function readIdentity(
3333+ did: string
3434+): Promise<{ handle: string | null; pds: string | null; resolved_at: number } | null> {
3535+ return db
3636+ .prepare("SELECT handle, pds, resolved_at FROM identities WHERE did = ?")
3737+ .bind(did)
3838+ .first();
3939+}
4040+4141+function slingshotReturns(body: { did?: string; handle?: string; pds?: string }) {
4242+ fetchSpy.mockResolvedValue(new Response(JSON.stringify(body), { status: 200 }));
4343+}
4444+4545+beforeEach(async () => {
4646+ db = await createTestDbWithSchema();
4747+ __resetPdsCachesForTests();
4848+ fetchSpy = vi.spyOn(global, "fetch");
4949+});
5050+5151+afterEach(() => {
5252+ fetchSpy.mockRestore();
5353+ __resetPdsCachesForTests();
5454+});
5555+5656+describe("getPDS heals a PDS-but-no-handle row", () => {
5757+ it("re-resolves and backfills the missing handle", async () => {
5858+ // A partial resolution persisted earlier: PDS known, handle null.
5959+ await seedIdentity(DID, null, PDS, Date.now());
6060+ // Slingshot now returns the handle.
6161+ slingshotReturns({ did: DID, handle: "alice.test", pds: PDS });
6262+6363+ const pds = await getPDS(DID, db);
6464+6565+ expect(pds).toBe(PDS);
6666+ expect(fetchSpy).toHaveBeenCalled(); // it did re-resolve, not strand
6767+ expect((await readIdentity(DID))?.handle).toBe("alice.test");
6868+ });
6969+7070+ it("still serves the known PDS if the heal resolution fails", async () => {
7171+ await seedIdentity(DID, null, PDS, Date.now());
7272+ fetchSpy.mockRejectedValue(new Error("slingshot down"));
7373+7474+ const pds = await getPDS(DID, db);
7575+7676+ expect(pds).toBe(PDS); // known PDS still returned
7777+ expect((await readIdentity(DID))?.handle).toBeNull();
7878+ });
7979+8080+ it("does NOT re-resolve a complete row (PDS + handle)", async () => {
8181+ await seedIdentity(DID, "bob.test", PDS, Date.now());
8282+8383+ const pds = await getPDS(DID, db);
8484+8585+ expect(pds).toBe(PDS);
8686+ expect(fetchSpy).not.toHaveBeenCalled(); // short-circuit preserved
8787+ expect((await readIdentity(DID))?.handle).toBe("bob.test");
8888+ });
8989+});
9090+9191+describe("refreshStaleIdentities does not clobber a known handle", () => {
9292+ it("keeps the existing handle when slingshot omits one", async () => {
9393+ // Stale row with a good handle.
9494+ await seedIdentity(DID, "carol.test", PDS, 0);
9595+ // Partial response: PDS but no handle.
9696+ slingshotReturns({ did: DID, pds: PDS });
9797+9898+ await refreshStaleIdentities(db, [DID]);
9999+100100+ const row = await readIdentity(DID);
101101+ expect(row?.handle).toBe("carol.test"); // preserved, not nulled
102102+ expect(row?.pds).toBe(PDS);
103103+ expect(row?.resolved_at).toBeGreaterThan(0); // refresh did run
104104+ });
105105+106106+ it("still applies a changed handle (non-null overwrites)", async () => {
107107+ await seedIdentity(DID, "carol.test", PDS, 0);
108108+ slingshotReturns({ did: DID, handle: "carol.new", pds: PDS });
109109+110110+ await refreshStaleIdentities(db, [DID]);
111111+112112+ expect((await readIdentity(DID))?.handle).toBe("carol.new");
113113+ });
114114+});
+12-11
packages/contrail-appview/src/core/backfill.ts
···351351352352 const dids = [...byDid.keys()];
353353354354- // Resolve PDS endpoints in background (populates in-memory cache)
355355- const resolvePromise = (async () => {
356356- for (let i = 0; i < dids.length; i += 200) {
357357- await Promise.allSettled(
358358- dids.slice(i, i + 200).map((did) =>
359359- getPDS(did as Did, db, config).catch(() => {})
360360- )
361361- );
362362- }
363363- })();
354354+ // Resolve PDS endpoints up front (populates the in-memory cache) rather
355355+ // than concurrently with the backfill passes below. Overlapping the two put
356356+ // identity resolution and record backfill in contention for slingshot at
357357+ // once, and the partial responses that produced (a PDS without a handle)
358358+ // got persisted and stranded. Resolving first keeps that load separate.
359359+ for (let i = 0; i < dids.length; i += 200) {
360360+ await Promise.allSettled(
361361+ dids.slice(i, i + 200).map((did) =>
362362+ getPDS(did as Did, db, config).catch(() => {})
363363+ )
364364+ );
365365+ }
364366365367 let roundBackfilled = 0;
366368 let usersComplete = 0;
···472474 }
473475 }
474476475475- await resolvePromise;
476477 totalBackfilled += roundBackfilled;
477478478479 // If nothing was backfilled this round, we're stuck