# hatk project This is an AT Protocol application built with [hatk](https://github.com/hatk-dev/hatk). Read the project's lexicons in `lexicons/` to understand the data model. Types are generated from lexicons into `hatk.generated.ts` — never edit this file directly. ## Project structure | Directory | Purpose | | ----------- | ----------------------------------------------------------------------------------- | | `lexicons/` | AT Protocol lexicon schemas (JSON). Defines collections and XRPC methods | | `server/` | All server-side code: feeds, XRPC handlers, hooks, labels, OG routes, setup scripts | | `app/` | SvelteKit frontend (routes, components, styles) | | `seeds/` | Test data seeding scripts for local development | | `test/` | Test files (vitest). Run with `vp test` | | `public/` | Static files served at the root | ## Key files - `hatk.config.ts` — project configuration (see `defineConfig` for type info) - `hatk.generated.ts` — auto-generated server types and helpers. Regenerate with `hatk generate types` - `hatk.generated.client.ts` — auto-generated client-safe types and `callXrpc`. Never import `hatk.generated.ts` from frontend code ## The `$hatk` alias Server files in `server/` import from `$hatk`: ```ts import { defineFeed, views, type Status } from "$hatk"; ``` SvelteKit routes and components import from `$hatk/client`: ```ts import { callXrpc, getViewer } from "$hatk/client"; ``` `$hatk` resolves to `hatk.generated.ts` and `$hatk/client` to `hatk.generated.client.ts`. The Vite plugin handles this in dev/build. In tests and production, a Node.js module resolve hook handles it. ## Commands Run `npx hatk --help` for the full list of commands. Use `npx hatk generate` to scaffold new feeds, xrpc handlers, labels, and lexicons rather than creating files manually. These generate files with the correct imports. After modifying lexicons, always run `npx hatk generate types` to update the generated types. ## The vitest overrides `vp test` runs Vitest, but vite-plus pins it to an exact version — 0.3.0 depends on `vitest@4.1.11` and on every `@vitest/*` package at that same exact version. Moving to Vitest 5 therefore takes an `overrides` block in `package.json` that lifts the whole set together; overriding `vitest` alone leaves `@vitest/expect` and friends at 4.x against a 5.x runner, which breaks at import time. Two entries are there only to keep `npm install` resolvable, not because we use them. `@vitest/browser-playwright` and `@vitest/browser-webdriverio` are optional peers of vite-plus at 4.1.11, and Vitest 5 wants `>=5.0.0`; webdriverio has no 5.0.0 release yet, so it is pinned to `5.0.0-rc.1`. Vitest browser mode is unused here — the browser tests in `test/browser/` are plain Playwright, run separately with `npm run test:browser`. Drop the whole block once vite-plus ships a release built on Vitest 5. ## Running - `npm run dev` — start dev server (hatk + SvelteKit + PDS) - `npm run build` — build for production (SvelteKit outputs to `build/`) - `npm start` — start production server (hatk + SvelteKit via `build/handler.js`) - `npm test` — run tests ## Permissioned spaces in dev Shared galleries run on permissioned data (proposal 0016), which the reference PDS on 2583 does not serve. `docker compose` also brings up two pds.js instances, on 2584 and 2585, with `PDS_ENABLE_SPACES=true`. Two, because pds.js hosts one account per instance and a space is only interesting once a member's repo lives on a host the authority does not control. ```sh docker compose up -d ./seeds/pdsjs-accounts.sh # needs a pds.js checkout; PDSJS_DIR if not ~/code/pds.js ``` The script prints a DID per account. **Log in with the DID, not the handle** — handle resolution goes through the dev relay, which is the PDS on 2583, and it has never heard of these accounts. Both instances are addressed as `pdsjs-a.localhost` / `pdsjs-b.localhost` rather than `localhost`. A member's PDS notifies the authority's PDS directly on every space write, so both have to reach each other by the hostname in the DID document, and `localhost` inside a container is that container. macOS resolves `*.localhost` to 127.0.0.1, and `extra_hosts` maps the same names to the host gateway inside the containers, so one name works from everywhere. The image defaults to `atcr.io/chadtmiller.com/pds.js:latest`, which needs `docker login atcr.io` (handle + app password). To run a local build instead: ```sh docker build -t pds.js:local ~/code/pds.js PDSJS_IMAGE=pds.js:local docker compose up -d ``` Space writes never reach a firehose, so nothing these accounts do in a space is indexed by the appview. That is the protocol working as designed, not a dev-env limitation — the app assembles a space by reading member repos directly. ### The second implementation pds.js is not the only server that serves spaces. [zds](https://tangled.org/zat.dev/zds) does too, and it is stricter, so it is the one that finds our assumptions: - It validates the requested OAuth scope against the `scope` in our published client metadata document. Conditional scopes widen the request past that document unless the extra scopes are registered on the client too — see `spaceScopes` in `hatk.config.ts`. - `com.atproto.simplespace.createSpace` requires `did`, and reads `policy` and `appAccess` from a nested `config` with the policy as a bare string. The reference `@atproto/pds` alpha has no `simplespace` since `0.0.0-spaces-alpha-20261029171913`: a space is created with `com.atproto.space.createSpace` (`spaceType`, `skey`, and a `policy` such as `{$type: "com.atproto.memberspace.policy"}`), and its member list kept with `com.atproto.memberspace.putMember`, `removeMember` and `listMembers`, where `read` and `write` are required. `server/spaces/client.ts` asks for those names first and falls back to `simplespace` on a 404 or 501. - Space reads name the repo `repo`, never `did`. pds.js hosts one account and falls back to it, which hides the difference. - `com.atproto.space.listRepos` is credential-only. A session does not work there even for the account that owns the space. When touching the space client, check a call against both servers' handlers: `packages/spaces/src/handlers/` in pds.js, `src/atproto/space.zig` in zds. ## Photo writes always send `alt` `@atproto/pds` validates writes against schemas compiled into the server, not the ones grain.social publishes (`validateRecord` in `packages/pds/src/repo/prepare.ts`). Its copy of `social.grain.photo` requires `alt`, so a photo without it is refused, and hatk reports that as a 500 on `dev.hatk.applyWrites`. Send `alt: ""` when there is no description. The same goes for any schema change: publishing it does not reach these servers. A write has to stay valid against the copy they ship until they upgrade. ## Minimum app versions `static/app-version.json`, served at `https://grain.social/app-version.json`, is the oldest build of each app this server still serves. The iOS and Android apps read it on launch and on returning to the foreground, and a build below its minimum shows a full-screen "Update Grain" that cannot be dismissed. Each app remembers the last minimum it read, so a blocked build stays blocked offline. The numbers are build numbers, not marketing versions: `ios.minimumBuild` is `CFBundleVersion` (`CURRENT_PROJECT_VERSION` in grain-ios's `project.yml`), `android.minimumVersionCode` is `versionCode`. `0` blocks nothing. Raise one only once the build it names is live in its store, or people are told to install an update they cannot get. ## Production debugging Production is a single Hetzner host, `167.233.237.163`, running the appview, the PDS, imgproxy, porxie, Caddy and the pds.js check runner as Docker Compose services under `/etc/grain`. Infrastructure lives in `~/code/hetzner-infra`; `docs/migration-plan.md` there is the record of how it got that way. ```sh ssh root@167.233.237.163 docker compose -f /etc/grain/docker-compose.yml ps docker compose -f /etc/grain/docker-compose.yml logs -f grain grain-deploy # rebuild the appview from GitHub main, restart ``` ### Querying the database The database is at `/mnt/data/grain/grain.db` on the host, mounted into the container as `/data/grain.db`. `sqlite3` and `duckdb` live **inside** the image, not on the host, so a query runs in a throwaway container: ```sh docker run --rm -v /mnt/data/grain:/data --entrypoint sh grain:current -c ' sqlite3 /data/grain.db "SELECT COUNT(*) FROM [social.grain.photo];" ' ``` Use `docker run`, not `docker compose run`. The grain service has a `build:` context, so compose rebuilds the appview from source before it will give you a shell — several minutes to run one query. The appview holds a write lock while indexing. A large batch of statements can sit behind it indefinitely: pass `-cmd ".timeout 30000"`, keep transactions small, and expect a bulk import to take far longer than the row count suggests. Table names contain dots, so bracket them: `[social.grain.photo]`. ### What the appview cannot rebuild Everything under a collection name is re-derivable — backfill fetches it from the network. The underscore-prefixed tables are not: `_oauth_keys` (change them and every session is void), `_oauth_sessions`, `_push_tokens`, `_preferences`, `_labels`, `_mutes`, `_story_views`, `_reports`, `_space_invites`, `_space_support`, and the `status` column of `_repos`, which is where an admin takedown lives. `_repos` is the trap: the row is mostly backfill bookkeeping that rebuilds itself, but `status` is administrator intent that nothing reconstructs. Treating the table as derived once silently reinstated three taken-down accounts. Backups run nightly to `/var/backups/grain` and offsite to Bunny; the PDS blob bucket mirrors from R2 to Bunny weekly. ## Extracting reusable types from lexicons When an XRPC output has inline objects you want to reference independently (e.g. in component props or `$state`), extract them as a named `ref` in the lexicon. The codegen will export a standalone type. **Before** — inline object, no reusable type: ```json "output": { "schema": { "type": "object", "properties": { "links": { "type": "array", "items": { "type": "object", "required": ["service", "label", "url"], "properties": { "service": { "type": "string" }, ... } } } } } } ``` **After** — extract as a named def, reference with `#ref`: ```json "output": { "schema": { "type": "object", "properties": { "links": { "type": "array", "items": { "type": "ref", "ref": "#externalLink" } } } } }, "externalLink": { "type": "object", "required": ["service", "label", "url"], "properties": { "service": { "type": "string" }, ... } } ``` Then run `npx hatk generate types`. The generated code will export `ExternalLink` as its own type, importable from `$hatk/client`: ```ts import { callXrpc, type ExternalLink } from "$hatk/client"; let links: ExternalLink[] = $state([]); ``` If the type name collides with a component import (e.g. `ExternalLink.svelte`), alias it: ```ts import ExternalLink from "$lib/components/molecules/ExternalLink.svelte"; import { type ExternalLink as ExternalLinkType } from "$hatk/client"; ``` ## Custom indexes hatk auto-creates indexes on `indexed_at DESC`, `did`, and child table columns. For app-specific queries (e.g. ordering by `played_time`), add custom indexes in a setup script: ```ts // server/setup/create-indexes.ts import { defineSetup } from "$hatk"; export default defineSetup(async (ctx) => { const { db } = ctx; await db.run( `CREATE INDEX IF NOT EXISTS idx_fm_teal_alpha_feed_play_played_time ON "fm.teal.alpha.feed.play"(played_time DESC)`, ); }); ``` Setup scripts run on every startup before the server accepts requests. Use `CREATE INDEX IF NOT EXISTS` so they're idempotent. To create an index on prod immediately without redeploying, use the `docker run` pattern above. ### SQLite datetime comparison gotcha SQLite's `datetime('now', '-4 hours')` returns space-separated format (`2026-03-16 12:00:00`) while ISO timestamps use `T` separator (`2026-03-16T12:00:00Z`). String comparison breaks because `T` > space. Use `strftime` for correct comparisons: ```sql -- WRONG: matches too many rows WHERE played_time >= datetime('now', '-4 hours') -- CORRECT: ISO format matches stored timestamps WHERE played_time >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-4 hours') ```