Lexification: migrating a service package
Moves a package from @atproto/lex-cli's lex gen-server output to
@atproto/lex schemas plus @atproto/xrpc-server routing.
Every service in this repo — packages/pds, packages/bsky, packages/ozone —
is already migrated, so this skill now applies to packages outside the repo, or
to a stray route chain left behind. Read the migrated services before
inventing a pattern.
Which skill applies
| Situation | Skill |
|---|---|
Package has src/lexicon/ (singular) and route chains |
this skill |
Adding a route to a package already on server.add() |
xrpc-server |
The same package's outbound AtpAgent / agent.api.… calls |
lexification-client |
A service migration almost always needs this skill and
lexification-client: one package both serves routes and calls other services.
Do the server half first — it forces src/lexicons/ into existence, which the
client half then imports from.
Guiding principles
- Type-level changes over runtime changes. Working runtime logic stays; what changes is where types and constants come from. A migration diff that alters behavior is a bug in the migration.
- Migrate one namespace of route files at a time, building in between. A route file is self-contained, so a broken one doesn't block the rest.
- Leave test call sites on
AtpAgent. Tests double as the regression check that the migration didn't change behavior — swapping the client they use destroys that signal. Tests that import types or constants from the deletedsrc/lexicon/do have to move (details below).
Order of work
- Project config — swap codegen, deps, gitignore, delete
src/lexicon/. → references/project-config.md - Server bootstrap —
createServer,Servertype, Express mount. - Route files —
server.<ns>.<method>(…)→server.add(schema, …). → references/routes.md - Constants and guards —
ids.*,isX(), token exports,$build. → references/types-and-guards.md - Outbound calls — lexification-client.
- Branded types at function/interface/DB boundaries. → references/types-and-guards.md
Steps 1–2 are a single non-splittable commit (the package won't compile in between). Steps 3–6 are incremental.
Server bootstrap
The only structural change in the whole migration. createServer and Server
stop coming from generated code:
- import { createServer } from './lexicon/index.js'
+ import { createServer } from '@atproto/xrpc-server'
- let server = createServer({
+ const server = createServer([], {
validateResponse: config.debugMode,
payload: { /* … */ },
})
- app.use(server.xrpc.router)
+ app.use(server.router)
The new first argument is an array of legacy LexiconDocs for routes not
backed by a generated schema; schema-based setups pass []. Option names
(validateResponse, payload, catchall, rateLimits, errorParser) are
unchanged — see packages/pds/src/index.ts.
In route files, Server comes from the same package:
- import { Server } from '../../../../lexicon/index.js'
+ import type { Server } from '@atproto/xrpc-server'
Import mapping
| Before | After |
|---|---|
../lexicon (Server, createServer) |
@atproto/xrpc-server |
../lexicon/lexicons (ids) |
../lexicons/index.js → .$type / .$lxm |
../lexicon/types/… (record & def types) |
../lexicons/index.js → .Main, .ViewRecord, … |
../lexicon/types/… (QueryParams, OutputSchema) |
../lexicons/index.js → .$Params, .$OutputBody |
../lexicon/types/… (isX, validateX) |
../lexicons/index.js → .$isTypeOf, .$matches, .$safeValidate |
../lexicon/util ($Typed, Un$Typed) |
@atproto/lex |
@atproto/api (AtpAgent) |
@atproto/lex (Client) |
@atproto/lexicon (jsonStringToLex, stringifyLex) |
@atproto/lex (lexParse, lexStringify) |
@atproto/lexicon (BlobRef) |
@atproto/lex (BlobRef) |
@atproto/xrpc (HeadersMap) |
@atproto/xrpc-server (Headers, usually aliased) |
@atproto/xrpc (XRPCError, thrown by a caller) |
@atproto/lex (XrpcError) |
multiformats/cid (CID) |
@atproto/lex (Cid, parseCid) |
@atproto/xrpc-server keeps its own XRPCError for errors handlers throw.
The casing distinguishes them: XRPCError server-side (concrete, thrown),
XrpcError client-side (abstract, caught). Both can appear in one file.
@atproto/lex re-exports lex-client, lex-schema, lex-data, and
lex-json, so Cid, BlobRef, lexParse, and $Typed all resolve from it.
@atproto/lex-cbor is not re-exported. Importing from either the umbrella
or the sub-package is fine; match whatever the file already does.
Pitfalls
$typevs$lxmvs$nsid.$typeis the record/object type string (app.bsky.feed.post.$type→'app.bsky.feed.post'), used for collections and union tags.$lxmis the method id, used in auth and proxy checks.$nsidis the raw NSID, needed fordefsdocuments that have nomain. They can hold identical strings, so a wrong one type-checks and only fails at runtime.$matchesvs$isTypeOf.$isTypeOfonly reads the$typetag;$matchesvalidates the whole value. Substituting$isTypeOfwhere the data is unvalidated silently accepts malformed records. See references/types-and-guards.md.encodingwidening affects only the bare-handler form ofserver.add, not the object form. Don't sprinkleas consteverywhere — see references/routes.md.- Branded strings arriving from outside TypeScript's view — protobuf,
Kysely rows,
JSON.parse— are plainstring. Cast once at the boundary (as DidString), never deep in the call graph. - Generated
src/lexicons/is gitignored. It won't exist untilpnpm run codegen(or a build) runs. "Cannot find module '../lexicons/index.js'" means codegen hasn't run, not that the import is wrong.
Tests
Test call sites keep using AtpAgent, deliberately (see principles above).
dev-env exposes both getAgent(): AtpAgent and getClient(): Client, so a
new-style client is available when a test genuinely needs one.
What must move is anything a test imported from the now-deleted
src/lexicon/. Two valid destinations, both already used in migrated
packages:
- import { ids } from '../../src/lexicon/lexicons'
- import { isRepoRef } from '../../src/lexicon/types/com/atproto/admin/defs'
+ import { com } from '../../src/lexicons/index.js' // generated schemas
+ import { $Typed, ComAtprotoAdminDefs, ids } from '@atproto/api' // legacy equivalents
Prefer ../../src/lexicons/index.js for new code; @atproto/api is the
lower-churn option when the surrounding test is otherwise untouched. Do not
add @atproto/api back to a package's dependencies just for this — it is a
devDependency in pds and only a runtime dependency in bsky because of
getAgeAssuranceRegionConfig, unrelated to tests.
Before writing or restructuring tests, use the
testing skill — pds and ozone are jest, bsky is
vitest.
Related skills
xrpc-server documents the target route API in full
and is the better reference once a package is migrated.
lex-setup owns codegen wiring,
lex-schema the $-accessors, and
lex-data branded strings, CIDs, and blobs.