~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
302 lines · 12.6 KB Raw History

hatk project

This is an AT Protocol application built with 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

DirectoryPurpose
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:

import { defineFeed, views, type Status } from "$hatk";

SvelteKit routes and components import from $hatk/client:

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.

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:

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 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.

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:

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:

"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:

"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:

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:

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:

// 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:

-- 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')