Install the third-party lexicons we already talk to
Every raw /xrpc/ fetch in the app targets a schema someone else publishes:
Slingshot's resolveMiniDoc, atproto's repo/sync methods, and five Bluesky
appview queries. lex install pins each one by CID in lexicons.json and
follows its refs, so pnpm lex:build now generates typed schemas for them
alongside our own — the groundwork for replacing those hand-built URLs with
@atproto/lex clients.
lex:verify passes --no-save: the CID comparison uses the parsed
document, so re-formatting is safe, but --save (on by default) rewrites the
manifest as 2-space JSON and would leave pnpm lint failing after every
verification run.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Add the shared lex client helpers
Every raw /xrpc/ fetch in the app repeats the same four things: build a URL
by hand, check res.ok, parse, then re-validate against the schema it already
imported. These are the two pieces that replace that plumbing, so the call
sites can shrink to a schema and its params.
atproto/xrpc.ts says how a service is addressed. Third-party hosts get a base
URL and an injectable fetch — the seam the isomorphic modules already test
through. Our own appview gets a bare relative-path handler instead: lex needs
no origin, so a .svelte.ts singleton can construct one at module scope while
SSR evaluates it, and the tests that read the /xrpc/… string still see it.
server/slingshot.ts is actors.ts's slingshot() grown a schema. Same
philosophy — only 200 and 400 are answers, everything else is retry-later
rather than a row blanked to nulls — with a lexicon mismatch now landing on the
retry side too, which is why validation stays strict. actors.ts keeps its own
copy until its callers move over.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Move browser stores' own-appview reads onto typed lex clients
records.svelte.ts, opt-outs.svelte.ts and optout.svelte.ts each hand-built a /xrpc/ URL and manually parsed the response against the output schema. Route them through localXrpc/localXrpcFresh's Client.call instead, which validates both the request params and the response body, and let failureStatus supply the "(status)" suffix on the one message that carries it.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Read the appview and the typeahead through lex
The four browser-side reads of someone else's appview — a page of follows,
the follow count under it, the profiles behind an engagement ranking, and
the handle typeahead — were hand-built URLs and hand-written shape checks.
They go through xrpc() and the installed lexicons now, so the schema
decides what a valid answer looks like and an off-lexicon one lands in the
error path each call site already had.
parseFollowsPage becomes toFollowActors: a validated page in, rows out.
What is left is editorial — drop handle.invalid, treat a blank display
name as none, re-point avatars at the blob proxy — since a row without a
did or a handle can no longer reach it. followsUrl and FollowsPage go
with it, and so does engagement's URLSearchParams workaround.
The typeahead's default host is a third-party implementation of
app.bsky.actor.searchActorsTypeahead, so a comment says where the
strictness knob is should it ever answer off-lexicon.
Aborts needed a second look: lex reports a cancelled request as an ordinary
failure, so HandleInput asks its own signal instead of matching on
DOMException.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Move leaderboard, vote, and ballot pages onto the typed lex client
Replace the raw /xrpc/ fetch + manual output.schema.parse calls in
+page.svelte, vote/+page.svelte, and me/+page.svelte with localXrpc.call,
picking up param parsing and output validation from the lexicon schemas.
Fixes an abort-detection bug along the way: lex wraps a cancelled request
in XrpcInternalError rather than a DOMException, so an aborted keystroke
in the leaderboard search now checks the request's own captured signal
instead of matching on an error class that no longer applies.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Move topPosts/counts.ts's getPosts call onto @atproto/lex
fetchBatch now calls xrpcSafe(getPostsQuery) instead of hand-rolling the query string and status/body checks; the count() coercion helper and the "no posts array" hand-check are gone now that lex validates the response against the schema. Test stubs need a real CID and the postView fields (author/record/indexedAt) the old hand-rolled parser never checked.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Move the engagement scan's listRecords reads onto @atproto/lex
fetchPage now calls xrpc(serviceAgent(...), listRecordsQuery, ...) instead of building the URL by hand and loosely parsing the JSON body; strict lex parsing validates each page against the listRecords schema instead. Drops listRecordsUrl/parseListRecordsPage and their tests; fixture records gain the cid the schema requires.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Fix fetchVoterStatus docstring to match what fresh actually does
The previous wording claimed both the poll and the post-write re-sync need the server's now, but only refreshStatus() (the poll) passes fresh: true; refresh()'s post-write re-sync passes false and does not bypass the cache. Point at the call site's existing accurate comment instead of duplicating (and contradicting) it.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Ask Slingshot through the lexicons, not by hand-built URL
The four server-side Slingshot callers now go through Task 1's
slingshot(url, schema, params): getRecord and resolveMiniDoc build
their own query strings from the generated schemas, and their answers
arrive validated. That deletes the local slingshot()/SlingshotAnswer
in actors.ts, serviceAuth.ts's hand-rolled isMiniDoc, and
identity.ts's isDid re-check — the lexicon's format: did is the
guard that keeps a handle-shaped answer from looping the profile
redirect.
Validation stays strict, so a 200 the lexicon refuses is a no-answer
rather than a half-read row: a minidoc missing pds/signing_key now
leaves a cached profile alone instead of overwriting it. The one
distinction the code still draws by hand is RecordNotFound vs. any
other 400 — "no profile record" against "the repo is gone".
getRepo stays a raw fetch, with only its URL built from the schema:
lex-client reads a binary body with arrayBuffer(), which would buffer
a whole 512 MiB export and undo the streaming byte-cap the walk needs.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Move Slingshot browser reads onto typed lex xrpcSafe calls
fetchBskyProfile and resolveHandleToDid now build their two Slingshot requests through xrpcSafe against resolveMiniDocQuery/getRecordQuery instead of hand-built URLs and a raw fetch/JSON parse, picking up lexicon-driven param encoding and response validation. Each call keeps its existing tolerate-failure behavior; fetchBskyProfile additionally guards the one gap in xrpcSafe's "never throws" contract (a signal that is already aborted before the request is built rejects instead of resolving to a failure), matching the pattern slingshot.ts already uses server-side.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Fix stale module-header prose in engagement-scan.ts
The header still described the listRecords read as "a plain fetch" after it
moved onto xrpc(serviceAgent(...)); reword to say what it is now —
unauthenticated, schema-validated through the lex client — and clarify that
fetch is still injectable via serviceAgent.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Fix: retry, don't throw, on an already-aborted signal in fetchBatch
xrpcSafe throws on a signal aborted before the request starts, so a shutdown mid-pull was bubbling past fetchCounts/pull.ts into start.ts's "pull threw" error log instead of the usual warn-level retry path. Wrap the xrpcSafe call in try/catch, matching the guard slingshot.ts already has for the same gap, and pin it with a test.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Fix misleading comment on fetchBskyProfile's branded did cast
The cast comment claimed the did is "always this app's own" — wrong: /profile/[did]/+page.svelte calls fetchBskyProfile with an arbitrary profile subject's did, not just the signed-in user's own (auth.svelte.ts).
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Document the @atproto/lex migration in README and CLAUDE.md
Covers lex:install/lex:verify and the pnpm format step, the installed third-party lexicons and their bsky*-prefixed re-exports, the two client helper modules (xrpc.ts, slingshot.ts), the getRepo streaming exception, and the abort/test-stub gotchas for future lex call sites.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Document the strict-parsing Cid/Uint8Array gotcha in CLAUDE.md
Review fix round 1: the third lex call-site gotcha (a record value read
through lex turns { $link } into a Cid and { $bytes } into a
Uint8Array, which is why blobCidOf accepts the parsed shape) was missing
from the "Client helpers" section. Cross-referenced from the pre-existing
blobCidOf sentence, and rewrapped a README.md line that had grown past
the paragraph's per-sentence line-break convention.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Say where a lex answer can be refused, and log what refused it
Strict validation gave every call site a fifth way to fail — a 200 whose
body the lexicon no longer accepts — and the comments still listed four.
Name it in ActorAnswer.retry, and give CLAUDE.md the gotcha that
matters in practice: one drifted declared field in postView or a
profileView* fails a whole batch at the four sites that read the live
appview or typeahead, strictResponseProcessing: false is the escape
hatch, and additive change is safe either way.
blobCidOf's three shapes were mislabelled after the move: Slingshot's
getRecord now arrives with ref already parsed to a Cid, and only
the Jetstream path still sees the raw { $link } JSON.
The diagnostics lost ground too. getPosts's failure warn dropped the
error object, so a network fault logged InternalServerError with an
undefined status and no cause; and slingshot's pre-flight catch shared
its message with the in-flight one, so a quiet shutdown read like
Slingshot going wrong. The engagement hydrate's empty catch now says
which batch it dropped, since it fails soft into a screen of raw DIDs.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Build the "(503)" suffix in one place, and cover the xrpc helpers
Seven call sites spelled the same status suffix out by hand, in two
inverted spellings of the one conditional. withStatus(copy, err) is
the whole line — copy, status when there is one, full stop — and every
site now produces the string it produced before, character for
character.
Two things fell out of collecting them. engagement-scan was throwing a
fresh Error and dropping last, so the real failure never reached the
console; it rides along as the cause now. And HandleInput guarded only
its catch: a superseded typeahead response still wrote suggestions,
could re-fire select, and flipped the spinner off under the request
that replaced it. It now checks signal.aborted on both paths, as
FollowSampler already did.
xrpc.test.ts pins what the helpers are for and had no coverage at all:
that localXrpc reaches our own appview at a bare relative path with
the browser cache untouched, that localXrpcFresh adds no-store, that
serviceAgent routes through the injected fetch with an absolute URL at
the base, and what failureStatus/withStatus make of a 503 and of a
fault nobody answered. serviceAgent's doc now says that lex resolves
against the base's origin, so a base carrying a path would lose it.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj
Put the typeahead spinner out when the field empties mid-flight
The abort guard added with the rest of the fix wave took FollowSampler's
finally without its counterpart: FollowSampler's restart clears
loading itself before starting the successor, and onInput's
short-input branch has no successor at all. So typing two characters and
then clearing the field while the request was in flight left "Searching…"
under an empty box until some later fetch happened along. That branch now
clears the flag next to the rest of the state it resets; it is the only
abort here that nothing follows.
Two corrections to the docs that came with the wave. The new CLAUDE.md
gotcha claimed strictResponseProcessing: false as the escape hatch for
a drifted declared field, which it is not — it is forwarded as { strict }
into payload parsing and schema.safeParse, so it relaxes data-model
strictness (a non-RFC-3339 datetime, a legacy blob ref, a float) and
nothing else. A field that changed shape or vanished wants the installed
lexicon updated instead, and the paragraph now says which is which. And
config.ts's {@link serviceAgent} pointed at a symbol the module does
not import, so it resolved to nothing; plain backticks.
Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj