chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
CLAUDE.md
Conventions for working in this repo. These are the ones an agent gets wrong without being told; everything else, read from the code.
Comments
Write a comment only when the information is not obtainable from reading the code. Then keep it concise.
Earns its place:
- A constraint with no syntax — a construction order one factory depends on, why
an unused
awaitis not dead code, why a magic number in an assertion is 3. - A domain fact the file cannot show — a single-account server answering
RepoNotFoundfor any other DID. - A current bug or quirk an assertion would otherwise look wrong against.
Delete otherwise:
- Restating the signature, the type, or the next line.
- Announcing what a well-named function does.
- Boilerplate repeated across sibling files.
- Justifying the design decision that produced the code. That belongs in the commit message.
- Meta-framing: "documents a bug rather than endorsing it", "note that…".
Phrase what stays plainly. Four rules, taken from ASD-STE100 — the parts of it that transfer to code. This is a house style, not a compliance claim; the standard's approved-word list is not applied.
- One idea per sentence. Twenty words maximum.
- Active voice. Keep the articles. Not "Only guards this isolate" but "This flag applies to one isolate".
- No metaphorical verbs. A
catchreceives an error; it does not swallow it. Code does not sit, live, bail, wrap, punt, or massage. - The same word for the same thing, every time. Not
handlerin one line andendpointin the next.
The third rule is the one that earns its place. A metaphorical verb is the most common way a comment reads well and means two things.
Describe the present, never the previous arrangement. No "previously X", "used to be Y", "the old 24h cap". In a refactor the tell is usually one small word rather than an explicit "previously":
| Don't | Do |
|---|---|
| "this delegation stays because…" | "the scheduler is reachable here because…" |
| "accountApi is still bound off the class" | "accountApi binds off the class" |
| "the handlers that remain methods" | "the handlers that are methods" |
| "warms the cache the same as before the move" | "the call warms the DID cache" |
| "handlers extracted out of pds.js get an entry" | "each handler module gets an entry" |
Each of those only parses if the reader knows the prior arrangement. Commit messages and changesets are exactly where "what changed relative to before" belongs — that distinction is the point.
Types
No any in JSDoc annotations in library or adapter code. Define real typedefs
for row and record shapes, or use the core port types
(import('@pdsjs/core/ports').SessionRecord). For genuinely dynamic values, type
as unknown and cast at the use site: /** @type {SomeType} */ (value).
Test mocks are the exception — casting a mock storage to /** @type {any} */ on
the way into a typed constructor is the established pattern across this suite.
Prefer structural types for a handler module's context over indexing off the
class (PersonalDataServer['accountApi']). An indexed annotation breaks as soon
as the method it names moves.
User-facing copy
No em dashes in anything a user sees — UI strings, banners, notices. Split the clauses into separate sentences instead.
Handler modules
The route handlers live in core/src/handlers/*.js, following the shape
@pdsjs/spaces uses: a createXHandlers(ctx) factory taking an explicit
context and returning a route table fragment, merged into this.routes in the
PersonalDataServer constructor alongside spaceRoutes. The class keeps
dispatch, authentication, and the commit and blob machinery.
docs/pds-decomposition.md is the record of how the handlers got there and the
mistakes that cost time. Read it before moving code out of pds.js.
- Name helpers for what they know (
crypto,mst,scope), handlers for what they serve (xrpc-sync,account-api). Thehandlers/prefix avoids colliding with the same-named helper modules incore/src/. - Handler modules stay out of the package export map.
- Never mix a move with a logic change in one commit. This touches DPoP, client auth and grants; bisectability is the safety net.
- Before moving a cluster, check inbound references from elsewhere in the same
file, not just other packages.
handleApiOverviewcalledthis.backupsAvailable()from 1,500 lines away and broke three tests.
Account interface
Two route tables describe /account, and they do not match. The React routes are
in packages/account-ui/src/app.jsx. The SECTIONS array in
core/src/account-ui.js feeds the server-rendered shell, and it names paths the
React router does not have. A request for a SECTIONS-only path renders the same
page as /account, with no error. Take the routes from app.jsx.
Checks
npm run ci is the gate CI runs: build:ui, biome, tsc --noEmit, then
test:coverage. CI enforces it on every push, so run the targeted steps below
while iterating and the full gate before committing.
pnpm install, notnpm install. The workspace dependencies use theworkspace:*protocol and npm exits withEUNSUPPORTEDPROTOCOL. CI installs withpnpm install --frozen-lockfile. Thenpm runscripts work either way.npm run build:uifirst on a fresh clone.@pdsjs/account-uiexports a gitignored bundle, and without it the node and cloudflare entrypoints fail to resolve.- Two typecheck projects, both run by
npm run typecheck.tsconfig.jsoncovers the server packages against Workers and node types.tsconfig.ui.jsoncovers the browser packages'src/libagainst the DOM, because the Workers types override it and make aUint8Arrayunassignable toBodyInit.types/vite-env.d.tsdeclares the twoimport.meta.envvalues, sincevite/clientresolves only inside a package that depends on vite. - The
.jsxfiles are in neither project. A component's destructured props read as required, so every call site omitting an optional one is an error: 1738 of them, against 136 for the lib modules. Typing the props is the work that would let them in. Until thennoUndeclaredVariablesis what catches a missing import there, which is the failure that reaches the browser. - Coverage floors live in
vitest.config.js, per area rather than global. A glob key aggregates every file it matches, so give each module its own entry rather than letting a well-covered one carry a bare one. - A minor changeset on a package outside the
fixedgroup in.changeset/config.jsonescalates the whole group to a major release. The mechanism:@pdsjs/nodelists the feature packages inpeerDependencies, and changesets major-bumps a dependent when a peer moves by minor.version-packages.jsrejects the escalation, but only at release time. Mark such packagespatchand give the group its own bump line. A new package's first release needs no changeset at all: set its version directly andchangeset publishpicks up anything not yet on npm. - Changesets may not mix
@pdsjs/account-uiwith public packages: it is private and unversioned, andchangeset versionrejects the mix. Leave it out of frontmatter; describe interface work under the packages that embed the built bundle. - The e2e suite is not part of the gate; it needs docker-compose. Run it with
PLATFORM=node USE_LOCAL_INFRA=false npx vitest run test/e2e.test.js. Three PLC tests fail without the local infra, which is expected. It binds port 2471, so ajust devstack in another workspace will block it.