diff --git a/README.md b/README.md index 04ba2a6..f990d97 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,6 @@ canonical endpoint (self-owned [lexicon](lexicons/tech.waow.typeahead.searchActo curl "https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=nate&limit=10" ``` -`q` is a prefix matched against handles and display names — or a full DID (`did:plc:…` / `did:web:…`), which performs an exact lookup and returns that actor alone. - also served at `/xrpc/app.bsky.actor.searchActorsTypeahead` as a drop-in alias for migrating off bluesky's appview — identical params and response, kept indefinitely. ## stack diff --git a/lexicons/tech.waow.typeahead.searchActors.json b/lexicons/tech.waow.typeahead.searchActors.json index e2211af..102d8a7 100644 --- a/lexicons/tech.waow.typeahead.searchActors.json +++ b/lexicons/tech.waow.typeahead.searchActors.json @@ -11,7 +11,7 @@ "properties": { "q": { "type": "string", - "description": "Search prefix; matched case-insensitively against handles and display names (CJK display names are matched via bigrams). A full DID (did:plc:… or did:web:…) instead performs an exact lookup and returns that actor alone." + "description": "Search prefix; matched case-insensitively against handles and display names (CJK display names are matched via bigrams)." }, "limit": { "type": "integer", diff --git a/scripts/smoke.py b/scripts/smoke.py index 4437682..f98b533 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -151,35 +151,6 @@ def test_prefix_match(base_url: str): check("prefix 'zzst' finds zzstoatzz.io", "zzstoatzz.io" in handles, f"got {handles[:5]}") -def test_did_search(base_url: str): - """q= resolves that exact actor at rank 1 via the hydrate path. - - Asserts the ENABLED behavior on purpose: a deploy with DID_SEARCH_DISABLED - accidentally set fails here instead of silently shipping the feature dark. - (validated live 2026-08-16: this DID resolves to zzstoatzz.io) - """ - print("\n--- DID search ---") - did = "did:plc:xbtmt2zjwlrfegqvch7fboei" # zzstoatzz.io - data, _ = fetch(f"{base_url}{XRPC_PATH}?q={did}&limit=10") - if not data or "_error" in data or "_http_error" in data: - check("fetch succeeded", False) - return - - actors = data.get("actors", []) - check("DID query returns exactly one actor", len(actors) == 1, f"got {len(actors)}") - if actors: - check("DID query rank 1 is that actor", actors[0].get("did") == did, - f"got {actors[0].get('did')}") - check("DID query hydrates the handle", actors[0].get("handle") == "zzstoatzz.io", - f"got {actors[0].get('handle')}") - - unknown = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" - data, _ = fetch(f"{base_url}{XRPC_PATH}?q={unknown}&limit=10") - check("unknown DID returns empty actors (200)", - data is not None and "_http_error" not in data and data.get("actors") == [], - f"got {data}") - - def test_ranking(base_url: str): """popularity ranking — a high-follower account must rank above low-follower namesakes that share its prefix. Guards the quality_score-clobber class of @@ -642,7 +613,6 @@ def main(): test_canonical_endpoint(args.url) test_known_handle(args.url) test_prefix_match(args.url) - test_did_search(args.url) test_ranking(args.url) test_cors(args.url) test_deprecated_param(args.url) diff --git a/services/src/index/index_search.zig b/services/src/index/index_search.zig index c9882ea..19f6b95 100644 --- a/services/src/index/index_search.zig +++ b/services/src/index/index_search.zig @@ -37,16 +37,16 @@ pub fn run(local: *LocalDb, allocator: Allocator, raw_query: []const u8, limit: defer arena_state.deinit(); const arena = arena_state.allocator(); + const norm = try normalize.query(arena, term); + if (norm.len == 0) { + try writer.writeAll("{\"actors\":[]}"); + return; + } + const lease = try local.checkoutRead(); defer lease.release(); - const candidates = if (isDid(term)) - try hydrate(arena, lease, &.{.{ .did = term, .score = 0 }}) - else blk: { - const norm = try normalize.query(arena, term); - if (norm.len == 0) break :blk &[_]searchmod.Candidate{}; - break :blk try execute(arena, lease, norm, null); - }; + const candidates = try execute(arena, lease, norm, null); var jw: json.Stringify = .{ .writer = writer }; try jw.beginObject(); @@ -58,17 +58,6 @@ pub fn run(local: *LocalDb, allocator: Allocator, raw_query: []const u8, limit: try jw.endObject(); } -/// A query that IS a DID skips key derivation and merge entirely: the DID goes -/// straight to hydrate(), so overlay-wins, tombstones, hidden flags, and the -/// live-actors safety belt apply exactly as they do for prefix results. Checked -/// on the sanitized term BEFORE normalize (which would mangle the colons). -fn isDid(term: []const u8) bool { - if (!std.mem.startsWith(u8, term, "did:plc:") and !std.mem.startsWith(u8, term, "did:web:")) - return false; - if (term.len == "did:plc:".len) return false; - return std.mem.indexOfScalar(u8, term, ' ') == null; -} - /// What the serving pipeline did with a query. Filled only when `/debug/search` /// asks for it — the point is that the explanation comes from the SAME code /// path that answered `/search`, not a parallel scorer that can drift out of @@ -138,24 +127,6 @@ pub fn debug(local: *LocalDb, allocator: Allocator, raw_query: []const u8, limit const arena = arena_state.allocator(); const term = searchmod.sanitize(raw_query); - - if (isDid(term)) { - const lease = try local.checkoutRead(); - defer lease.release(); - const candidates = try hydrate(arena, lease, &.{.{ .did = term, .score = 0 }}); - const n = @min(limit, candidates.len); - const rows = try arena.alloc(Report.Row, n); - for (candidates[0..n], rows) |*c, *r| r.* = .{ - .did = c.did, - .handle = c.handle, - .display_name = c.display_name, - .score = c.quality_score, - }; - var jw: json.Stringify = .{ .writer = writer }; - try jw.write(Report{ .q = raw_query, .normalized = term, .mode = "did", .merged = n, .candidates = rows }); - return; - } - const norm = if (term.len == 0) "" else try normalize.query(arena, term); var jw: json.Stringify = .{ .writer = writer }; @@ -409,16 +380,6 @@ test "promoteExact re-injects an exact hit the score cap evicted" { try testing.expectEqualStrings("tok2", out[2].did); } -test "isDid: accepts plc/web DIDs, rejects handles and phrases" { - try testing.expect(isDid("did:plc:xbtmt2zjwlrfegqvch7fboei")); - try testing.expect(isDid("did:web:zzstoatzz.io")); - try testing.expect(!isDid("did:key:zQ3sh")); // unsupported method - try testing.expect(!isDid("zzstoatzz.io")); - try testing.expect(!isDid("did:plc:abc def")); // internal space → ordinary query - try testing.expect(!isDid("did:plc:")); // empty method-specific id - try testing.expect(!isDid("")); -} - test "inClause builds the right placeholder count" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); diff --git a/src/handlers/search.test.ts b/src/handlers/search.test.ts deleted file mode 100644 index 472e9ea..0000000 --- a/src/handlers/search.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { expect, test } from "bun:test"; -import { isDidTerm } from "./search"; - -// mirrors the isDid test in services/src/index/index_search.zig — the worker -// gate and the search service must classify DID terms identically. -test("isDidTerm accepts plc/web DIDs", () => { - expect(isDidTerm("did:plc:xbtmt2zjwlrfegqvch7fboei")).toBe(true); - expect(isDidTerm("did:web:zzstoatzz.io")).toBe(true); -}); - -test("isDidTerm rejects handles, phrases, and unsupported methods", () => { - expect(isDidTerm("zzstoatzz.io")).toBe(false); - expect(isDidTerm("did:key:zQ3sh")).toBe(false); - expect(isDidTerm("did:plc:abc def")).toBe(false); - expect(isDidTerm("did:plc:")).toBe(false); - expect(isDidTerm("")).toBe(false); -}); diff --git a/src/handlers/search.ts b/src/handlers/search.ts index f80042b..7ac87b3 100644 --- a/src/handlers/search.ts +++ b/src/handlers/search.ts @@ -5,25 +5,6 @@ import { json, sanitize, avatarUrl, avatarFallbackUrl } from "../utils"; import { recordMetric, recordCacheHit, recordTrafficSource } from "../metrics"; import { throttledBackfill } from "../backfill"; -// Must agree with isDid() in services/src/index/index_search.zig — the worker -// gates exposure, the search service resolves the lookup. -export function isDidTerm(term: string): boolean { - return /^did:(plc|web):\S+$/.test(term); -} - -function actorView(r: ActorRow) { - return { - did: r.did, - handle: r.handle, - ...(r.display_name ? { displayName: r.display_name } : {}), - ...(r.avatar_url ? { avatar: avatarUrl(r.did, r.avatar_url, r.pds) } : {}), - ...(r.avatar_url && avatarFallbackUrl(r.did, r.avatar_url, r.pds) ? { avatarFallback: avatarFallbackUrl(r.did, r.avatar_url, r.pds) } : {}), - ...(r.associated && r.associated !== '{}' ? { associated: JSON.parse(r.associated) } : {}), - labels: JSON.parse(r.labels || '[]'), - ...(r.created_at ? { createdAt: r.created_at } : {}), - }; -} - export async function handleSearch( request: Request, db: TursoDB, @@ -48,14 +29,6 @@ export async function handleSearch( return json({ actors: [] }); } - // DID-query search: the backend resolves did:plc:/did:web: terms via its - // hydrate path. The capability lives there; the OFF switch lives here, at - // the layer with no-redeploy config (see DID_SEARCH_DISABLED in types.ts). - const didTerm = isDidTerm(term); - if (didTerm && env.DID_SEARCH_DISABLED) { - return json({ actors: [] }); - } - // Emergency kill switch: bypass the whole pipeline and proxy to Bluesky. // Set/unset via `bunx wrangler secret put|delete EMERGENCY_PROXY_BSKY`. // No cache writes here — old cache entries expire (60s) and the moment the @@ -137,6 +110,10 @@ export async function handleSearch( } } + // fallback: 3-tier ranking via turso. Overfetch prefix + FTS so the + // interleaved merge below has real candidates to pick from. + const ftsQuery = `"${term}"*`; + const overfetch = Math.min(limit * 5, 50); // Bounded, like the backend hop above it. // // This path now runs whenever the replica answers empty, not just when it is @@ -145,41 +122,6 @@ export async function handleSearch( // not have yet. Answering "nothing found" quickly beats answering correctly // eventually: the caller is a typeahead, and the next keystroke is 200ms away. const FALLBACK_BUDGET_MS = 2500; - - // DID fallback: the prefix/FTS tiers below can't match a DID, so the Turso - // authority check is a single primary-key read. - if (didTerm) { - let row: ActorRow | null = null; - try { - row = await Promise.race([ - db.prepare( - `SELECT did, handle, display_name, avatar_url, labels, created_at, associated, pds - FROM actors WHERE did = ?1 AND hidden = 0 LIMIT 1` - ).bind(term).first(), - new Promise((_, rej) => - setTimeout(() => rej(new Error("turso_fallback_timeout")), FALLBACK_BUDGET_MS)), - ]); - } catch (e: any) { - console.log(JSON.stringify({ event: "search_fallback_timeout", term, error: e?.message })); - return json({ actors: [] }); - } - - ctx.waitUntil(Promise.all([ - recordMetric(db, Date.now() - t0), - recordTrafficSource(db, request), - ])); - - const response = json({ actors: row ? [actorView(row)] : [] }); - const cacheable = new Response(response.clone().body, response); - cacheable.headers.set("Cache-Control", "public, max-age=60"); - ctx.waitUntil(cache.put(cacheKey, cacheable)); - return response; - } - - // fallback: 3-tier ranking via turso. Overfetch prefix + FTS so the - // interleaved merge below has real candidates to pick from. - const ftsQuery = `"${term}"*`; - const overfetch = Math.min(limit * 5, 50); const batch = db.batch([ db.prepare( `SELECT did, handle, display_name, avatar_url, labels, created_at, associated, pds @@ -270,7 +212,16 @@ export async function handleSearch( takePrefix = !takePrefix; } - const actors = merged.map(actorView); + const actors = merged.map((r) => ({ + did: r.did, + handle: r.handle, + ...(r.display_name ? { displayName: r.display_name } : {}), + ...(r.avatar_url ? { avatar: avatarUrl(r.did, r.avatar_url, r.pds) } : {}), + ...(r.avatar_url && avatarFallbackUrl(r.did, r.avatar_url, r.pds) ? { avatarFallback: avatarFallbackUrl(r.did, r.avatar_url, r.pds) } : {}), + ...(r.associated && r.associated !== '{}' ? { associated: JSON.parse(r.associated) } : {}), + labels: JSON.parse(r.labels || '[]'), + ...(r.created_at ? { createdAt: r.created_at } : {}), + })); // --- backfill: remove this block once at parity with Bluesky --- const hasGaps = actors.length < limit || actors.some((a) => !a.avatar); diff --git a/src/pages/docs.ts b/src/pages/docs.ts index b2e632f..452f683 100644 --- a/src/pages/docs.ts +++ b/src/pages/docs.ts @@ -105,10 +105,7 @@ export function docsPage(): string {
GET https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=...&limit=10

params are q and limit (1–100), returning - { "actors": [...] }. q is a prefix matched against - handles and display names — or a full DID (did:plc:… / - did:web:…), which performs an exact lookup and returns that - actor alone. this is the endpoint to use — + { "actors": [...] }. this is the endpoint to use — app.bsky.* names belong to bluesky's lexicons, and this service isn't bluesky. there's also a machine-readable summary at /llms.txt.

diff --git a/src/pages/home.ts b/src/pages/home.ts index 34c2a36..f448e1c 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -100,7 +100,7 @@ export function indexPage(): string {
- +
diff --git a/src/pages/llms.ts b/src/pages/llms.ts index 50b6852..656cdfc 100644 --- a/src/pages/llms.ts +++ b/src/pages/llms.ts @@ -9,8 +9,7 @@ experimental: may break or disappear — don't depend on it for anything critica GET https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=&limit=10 -- q: required. search prefix, matched against handles and display names. a full DID - (did:plc:… or did:web:…) instead performs an exact lookup and returns that actor alone. +- q: required. search prefix, matched against handles and display names. - limit: 1-100, default 10. - response: { "actors": [ { did, handle, displayName?, avatar?, associated?, labels?, createdAt? } ] } (the shape of app.bsky.actor.defs#profileViewBasic minus the authenticated "viewer" field) diff --git a/src/types.ts b/src/types.ts index 9242f54..38eac2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,13 +17,6 @@ export interface Env { /// Turn off with: /// bunx wrangler secret delete EMERGENCY_PROXY_BSKY EMERGENCY_PROXY_BSKY?: string; - /// Kill switch for DID-query search (q=did:plc:… / did:web:…). When set - /// (any non-empty value), DID-shaped queries return {actors:[]} without - /// reaching the backend — same no-redeploy pattern as EMERGENCY_PROXY_BSKY: - /// bunx wrangler secret put DID_SEARCH_DISABLED - /// bunx wrangler secret delete DID_SEARCH_DISABLED - /// The 60s edge cache means a flip takes up to a minute to fully apply. - DID_SEARCH_DISABLED?: string; /// Discord-compatible webhook. When set, the hourly cron posts here on a /// freshness STATE CHANGE (ok -> stale, stale -> ok). Set via: /// bunx wrangler secret put ALERT_WEBHOOK_URL