AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: complete docs overhaul for current API surface

Rewrite all documentation to reflect generated client helpers, SQLite-only,
SvelteKit remote commands, and login/logout/parseViewer from $hatk/client.

- Rewrite nav structure with new Frontend section
- Landing page with project tree and feature cards
- Getting Started: quickstart, project structure, configuration rewritten
- Guides: feeds, xrpc handlers, auth (new), seeds, labels, opengraph, hooks
- Frontend: 3 new pages (setup, data-loading, mutations)
- API reference: updated with dual auth (DPoP + cookies), callXrpc
- CLI reference: fixed src/ → app/, localhost → 127.0.0.1
- Remove obsolete: oauth.md, api-client.md, frontend.md, deployment.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+2375 -1450
+15 -9
docs/site/.vitepress/config.ts
··· 1 1 import { defineConfig } from 'vitepress' 2 2 3 3 export default defineConfig({ 4 - title: 'Hatk', 5 - description: 'Build AT Protocol applications from scratch.', 4 + title: 'hatk', 5 + description: 'Build AT Protocol applications with typed XRPC endpoints.', 6 6 7 7 themeConfig: { 8 8 nav: [ 9 9 { text: 'Guide', link: '/getting-started/quickstart' }, 10 + { text: 'Frontend', link: '/frontend/setup' }, 10 11 { text: 'CLI', link: '/cli/' }, 11 12 { text: 'API', link: '/api/' }, 12 13 ], ··· 23 24 { 24 25 text: 'Guides', 25 26 items: [ 26 - { text: 'Frontend (SvelteKit)', link: '/guides/frontend' }, 27 - { text: 'API Client', link: '/guides/api-client' }, 28 - { text: 'OAuth', link: '/guides/oauth' }, 29 27 { text: 'Feeds', link: '/guides/feeds' }, 30 28 { text: 'XRPC Handlers', link: '/guides/xrpc-handlers' }, 31 - { text: 'Labels', link: '/guides/labels' }, 29 + { text: 'Auth & OAuth', link: '/guides/auth' }, 32 30 { text: 'Seeds', link: '/guides/seeds' }, 33 - { text: 'OpenGraph Images', link: '/guides/opengraph' }, 31 + { text: 'Labels', link: '/guides/labels' }, 32 + { text: 'OpenGraph', link: '/guides/opengraph' }, 34 33 { text: 'Hooks', link: '/guides/hooks' }, 35 - { text: 'Deployment', link: '/guides/deployment' }, 34 + ], 35 + }, 36 + { 37 + text: 'Frontend', 38 + items: [ 39 + { text: 'SvelteKit Setup', link: '/frontend/setup' }, 40 + { text: 'Data Loading', link: '/frontend/data-loading' }, 41 + { text: 'Mutations', link: '/frontend/mutations' }, 36 42 ], 37 43 }, 38 44 { ··· 59 65 }, 60 66 ], 61 67 62 - socialLinks: [{ icon: 'github', link: 'https://github.com/bigmoves/atconf-workshop' }], 68 + socialLinks: [{ icon: 'github', link: 'https://github.com/bigmoves/hatk' }], 63 69 }, 64 70 })
+11 -7
docs/site/api/blobs.md
··· 8 8 Upload a binary blob (image, audio, etc.) via the authenticated user's PDS. 9 9 10 10 - **Type:** Procedure (POST) 11 - - **Auth:** Required 11 + - **Auth:** Required (session cookie or DPoP token) 12 12 - **Content-Type:** `*/*` (set to the blob's MIME type) 13 13 14 14 ### Request ··· 16 16 Send the raw binary data as the request body with the appropriate `Content-Type` header. 17 17 18 18 ```bash 19 - curl -X POST "http://localhost:3000/xrpc/dev.hatk.uploadBlob" \ 19 + curl -X POST "http://127.0.0.1:3000/xrpc/dev.hatk.uploadBlob" \ 20 20 -H "Authorization: DPoP <token>" \ 21 21 -H "Content-Type: image/jpeg" \ 22 22 --data-binary @photo.jpg ··· 25 25 ### Client usage 26 26 27 27 ```typescript 28 - const result = await api.upload(file) 28 + import { callXrpc } from "$hatk/client"; 29 + 30 + const result = await callXrpc("dev.hatk.uploadBlob", file); 29 31 // result.blob contains the blob reference 30 32 ``` 31 33 ··· 47 49 After uploading, reference the blob in a record field: 48 50 49 51 ```typescript 50 - const uploadResult = await api.upload(imageFile) 52 + import { callXrpc } from "$hatk/client"; 51 53 52 - await api.call('dev.hatk.createRecord', { 53 - collection: 'fm.teal.alpha.feed.play', 54 + const uploadResult = await callXrpc("dev.hatk.uploadBlob", imageFile); 55 + 56 + await callXrpc("dev.hatk.createRecord", { 57 + collection: "fm.teal.alpha.feed.play", 54 58 repo: userDid, 55 59 record: { 56 60 createdAt: new Date().toISOString(), 57 61 image: uploadResult.blob, 58 62 }, 59 - }) 63 + }); 60 64 ```
+7 -5
docs/site/api/feeds.md
··· 21 21 ### Example 22 22 23 23 ```bash 24 - curl "http://localhost:3000/xrpc/dev.hatk.getFeed?feed=recent&limit=20" 24 + curl "http://127.0.0.1:3000/xrpc/dev.hatk.getFeed?feed=recent&limit=20" 25 25 ``` 26 26 27 27 ```typescript 28 - const { items, cursor } = await api.query('dev.hatk.getFeed', { 29 - feed: 'recent', 28 + import { callXrpc } from "$hatk/client"; 29 + 30 + const { items, cursor } = await callXrpc("dev.hatk.getFeed", { 31 + feed: "recent", 30 32 limit: 20, 31 - }) 33 + }); 32 34 ``` 33 35 34 36 ### Response ··· 53 55 ### Example 54 56 55 57 ```bash 56 - curl "http://localhost:3000/xrpc/dev.hatk.describeFeeds" 58 + curl "http://127.0.0.1:3000/xrpc/dev.hatk.describeFeeds" 57 59 ``` 58 60 59 61 ### Response
+19 -11
docs/site/api/index.md
··· 13 13 14 14 ## Authentication 15 15 16 - Authenticated endpoints require an OAuth DPoP bearer token in the `Authorization` header: 16 + hatk supports two authentication methods: 17 + 18 + **Session cookies** (recommended for SvelteKit apps) -- `login()`, `logout()`, and `parseViewer()` from `$hatk/client` handle the full OAuth flow and store the session in an encrypted cookie. See the [Auth guide](/guides/auth). 19 + 20 + **DPoP browser tokens** -- for direct XRPC calls from external clients, pass an OAuth DPoP bearer token in the `Authorization` header: 17 21 18 22 ``` 19 23 Authorization: DPoP <token> 20 24 ``` 21 25 22 - Configure OAuth in your `config.yaml` to enable authentication. See [Configuration](/getting-started/configuration). 26 + Configure OAuth in your `hatk.config.ts` to enable authentication. See [Configuration](/getting-started/configuration). 23 27 24 28 ## Client usage 25 29 26 - The generated client provides typed methods for all endpoints: 30 + The generated `callXrpc()` function from `$hatk/client` provides typed access to all endpoints: 27 31 28 32 ```typescript 33 + import { callXrpc } from "$hatk/client"; 34 + 29 35 // Query (GET) 30 - const result = await api.query('dev.hatk.getRecords', { 31 - collection: 'fm.teal.alpha.feed.play', 36 + const { items, cursor } = await callXrpc("dev.hatk.getRecords", { 37 + collection: "fm.teal.alpha.feed.play", 32 38 limit: 10, 33 - }) 39 + }); 34 40 35 41 // Procedure (POST) 36 - const result = await api.call('dev.hatk.createRecord', { 37 - collection: 'fm.teal.alpha.feed.play', 42 + const { uri, cid } = await callXrpc("dev.hatk.createRecord", { 43 + collection: "fm.teal.alpha.feed.play", 38 44 repo: userDid, 39 45 record: { createdAt: new Date().toISOString() }, 40 - }) 46 + }); 41 47 42 - // Upload binary data 43 - const result = await api.upload(file) 48 + // Pass SvelteKit's fetch for SSR deduplication 49 + const data = await callXrpc("dev.hatk.getFeed", { feed: "recent" }, fetch); 44 50 ``` 51 + 52 + The optional third parameter `customFetch` accepts a fetch function. Pass SvelteKit's `fetch` from load functions to enable request deduplication between server and client renders. 45 53 46 54 ## Built-in endpoints 47 55
+1 -1
docs/site/api/labels.md
··· 14 14 ### Example 15 15 16 16 ```bash 17 - curl "http://localhost:3000/xrpc/dev.hatk.describeLabels" 17 + curl "http://127.0.0.1:3000/xrpc/dev.hatk.describeLabels" 18 18 ``` 19 19 20 20 ### Response
+13 -9
docs/site/api/preferences.md
··· 8 8 Get all preferences for the authenticated user. 9 9 10 10 - **Type:** Query (GET) 11 - - **Auth:** Required 11 + - **Auth:** Required (session cookie or DPoP token) 12 12 13 13 ### Example 14 14 15 15 ```bash 16 - curl "http://localhost:3000/xrpc/dev.hatk.getPreferences" \ 16 + curl "http://127.0.0.1:3000/xrpc/dev.hatk.getPreferences" \ 17 17 -H "Authorization: DPoP <token>" 18 18 ``` 19 19 20 20 ```typescript 21 - const { preferences } = await api.query('dev.hatk.getPreferences') 21 + import { callXrpc } from "$hatk/client"; 22 + 23 + const { preferences } = await callXrpc("dev.hatk.getPreferences"); 22 24 ``` 23 25 24 26 ### Response ··· 39 41 Set a single preference by key for the authenticated user. 40 42 41 43 - **Type:** Procedure (POST) 42 - - **Auth:** Required 44 + - **Auth:** Required (session cookie or DPoP token) 43 45 44 46 ### Input 45 47 ··· 51 53 ### Example 52 54 53 55 ```bash 54 - curl -X POST "http://localhost:3000/xrpc/dev.hatk.putPreference" \ 56 + curl -X POST "http://127.0.0.1:3000/xrpc/dev.hatk.putPreference" \ 55 57 -H "Authorization: DPoP <token>" \ 56 58 -H "Content-Type: application/json" \ 57 59 -d '{"key":"theme","value":"dark"}' 58 60 ``` 59 61 60 62 ```typescript 61 - await api.call('dev.hatk.putPreference', { 62 - key: 'theme', 63 - value: 'dark', 64 - }) 63 + import { callXrpc } from "$hatk/client"; 64 + 65 + await callXrpc("dev.hatk.putPreference", { 66 + key: "theme", 67 + value: "dark", 68 + }); 65 69 ``` 66 70 67 71 ### Response
+34 -24
docs/site/api/records.md
··· 19 19 ### Example 20 20 21 21 ```bash 22 - curl "http://localhost:3000/xrpc/dev.hatk.getRecord?uri=at://did:plc:abc/fm.teal.alpha.feed.play/123" 22 + curl "http://127.0.0.1:3000/xrpc/dev.hatk.getRecord?uri=at://did:plc:abc/fm.teal.alpha.feed.play/123" 23 23 ``` 24 24 25 25 ```typescript 26 - const { record } = await api.query('dev.hatk.getRecord', { 27 - uri: 'at://did:plc:abc/fm.teal.alpha.feed.play/123', 28 - }) 26 + import { callXrpc } from "$hatk/client"; 27 + 28 + const { record } = await callXrpc("dev.hatk.getRecord", { 29 + uri: "at://did:plc:abc/fm.teal.alpha.feed.play/123", 30 + }); 29 31 ``` 30 32 31 33 ### Response ··· 60 62 ### Example 61 63 62 64 ```bash 63 - curl "http://localhost:3000/xrpc/dev.hatk.getRecords?collection=fm.teal.alpha.feed.play&limit=10" 65 + curl "http://127.0.0.1:3000/xrpc/dev.hatk.getRecords?collection=fm.teal.alpha.feed.play&limit=10" 64 66 ``` 65 67 66 68 ```typescript 67 - const { items, cursor } = await api.query('dev.hatk.getRecords', { 68 - collection: 'fm.teal.alpha.feed.play', 69 + import { callXrpc } from "$hatk/client"; 70 + 71 + const { items, cursor } = await callXrpc("dev.hatk.getRecords", { 72 + collection: "fm.teal.alpha.feed.play", 69 73 limit: 10, 70 - }) 74 + }); 71 75 ``` 72 76 73 77 ### Response ··· 86 90 Create a record via the authenticated user's PDS. 87 91 88 92 - **Type:** Procedure (POST) 89 - - **Auth:** Required 93 + - **Auth:** Required (session cookie or DPoP token) 90 94 91 95 ### Input 92 96 ··· 99 103 ### Example 100 104 101 105 ```bash 102 - curl -X POST "http://localhost:3000/xrpc/dev.hatk.createRecord" \ 106 + curl -X POST "http://127.0.0.1:3000/xrpc/dev.hatk.createRecord" \ 103 107 -H "Authorization: DPoP <token>" \ 104 108 -H "Content-Type: application/json" \ 105 109 -d '{"collection":"fm.teal.alpha.feed.play","repo":"did:plc:abc","record":{"createdAt":"2025-01-01T00:00:00Z"}}' 106 110 ``` 107 111 108 112 ```typescript 109 - const { uri, cid } = await api.call('dev.hatk.createRecord', { 110 - collection: 'fm.teal.alpha.feed.play', 113 + import { callXrpc } from "$hatk/client"; 114 + 115 + const { uri, cid } = await callXrpc("dev.hatk.createRecord", { 116 + collection: "fm.teal.alpha.feed.play", 111 117 repo: userDid, 112 118 record: { createdAt: new Date().toISOString() }, 113 119 }) ··· 129 135 Create or update a record at a specific rkey via the authenticated user's PDS. 130 136 131 137 - **Type:** Procedure (POST) 132 - - **Auth:** Required 138 + - **Auth:** Required (session cookie or DPoP token) 133 139 134 140 ### Input 135 141 ··· 143 149 ### Example 144 150 145 151 ```bash 146 - curl -X POST "http://localhost:3000/xrpc/dev.hatk.putRecord" \ 152 + curl -X POST "http://127.0.0.1:3000/xrpc/dev.hatk.putRecord" \ 147 153 -H "Authorization: DPoP <token>" \ 148 154 -H "Content-Type: application/json" \ 149 155 -d '{"collection":"fm.teal.alpha.feed.play","rkey":"self","record":{"createdAt":"2025-01-01T00:00:00Z"}}' 150 156 ``` 151 157 152 158 ```typescript 153 - const { uri, cid } = await api.call('dev.hatk.putRecord', { 154 - collection: 'fm.teal.alpha.feed.play', 155 - rkey: 'self', 159 + import { callXrpc } from "$hatk/client"; 160 + 161 + const { uri, cid } = await callXrpc("dev.hatk.putRecord", { 162 + collection: "fm.teal.alpha.feed.play", 163 + rkey: "self", 156 164 record: { createdAt: new Date().toISOString() }, 157 - }) 165 + }); 158 166 ``` 159 167 160 168 ### Response ··· 173 181 Delete a record via the authenticated user's PDS. 174 182 175 183 - **Type:** Procedure (POST) 176 - - **Auth:** Required 184 + - **Auth:** Required (session cookie or DPoP token) 177 185 178 186 ### Input 179 187 ··· 185 193 ### Example 186 194 187 195 ```bash 188 - curl -X POST "http://localhost:3000/xrpc/dev.hatk.deleteRecord" \ 196 + curl -X POST "http://127.0.0.1:3000/xrpc/dev.hatk.deleteRecord" \ 189 197 -H "Authorization: DPoP <token>" \ 190 198 -H "Content-Type: application/json" \ 191 199 -d '{"collection":"fm.teal.alpha.feed.play","rkey":"123"}' 192 200 ``` 193 201 194 202 ```typescript 195 - await api.call('dev.hatk.deleteRecord', { 196 - collection: 'fm.teal.alpha.feed.play', 197 - rkey: '123', 198 - }) 203 + import { callXrpc } from "$hatk/client"; 204 + 205 + await callXrpc("dev.hatk.deleteRecord", { 206 + collection: "fm.teal.alpha.feed.play", 207 + rkey: "123", 208 + }); 199 209 ``` 200 210 201 211 ### Response
+9 -9
docs/site/api/search.md
··· 5 5 6 6 ## `dev.hatk.searchRecords` 7 7 8 - Full-text search across a collection using DuckDB's FTS extension with BM25 ranking. 8 + Full-text search across a collection using SQLite FTS5 with BM25 ranking. 9 9 10 10 - **Type:** Query (GET) 11 11 - **Auth:** None ··· 23 23 ### Example 24 24 25 25 ```bash 26 - curl "http://localhost:3000/xrpc/dev.hatk.searchRecords?collection=fm.teal.alpha.feed.play&q=radiohead" 26 + curl "http://127.0.0.1:3000/xrpc/dev.hatk.searchRecords?collection=fm.teal.alpha.feed.play&q=radiohead" 27 27 ``` 28 28 29 29 ```typescript 30 - const { items, cursor } = await api.query('dev.hatk.searchRecords', { 31 - collection: 'fm.teal.alpha.feed.play', 32 - q: 'radiohead', 33 - }) 30 + import { callXrpc } from "$hatk/client"; 31 + 32 + const { items, cursor } = await callXrpc("dev.hatk.searchRecords", { 33 + collection: "fm.teal.alpha.feed.play", 34 + q: "radiohead", 35 + }); 34 36 ``` 35 37 36 38 ### Response ··· 44 46 45 47 ### How it works 46 48 47 - Hatk builds a DuckDB full-text search index for each collection. The index is rebuilt periodically based on the `ftsRebuildInterval` config option (default: every 500 writes). 49 + Hatk builds a SQLite FTS5 full-text search index for each collection. String fields in your record lexicon definitions are automatically included in the index, which uses incremental updates so new records become searchable immediately. 48 50 49 51 Search uses BM25 ranking to order results by relevance. The `fuzzy` parameter (enabled by default) allows approximate matching for typos and partial terms. 50 - 51 - String fields in your record lexicon definitions are automatically included in the FTS index.
+24 -1
docs/site/cli/build.md
··· 11 11 hatk build 12 12 ``` 13 13 14 - Compiles and bundles the files in `public/` (or `src/` for Svelte projects) into optimized production assets. 14 + Compiles and bundles the SvelteKit frontend in `app/` into optimized production assets. 15 15 16 16 ## Deployment 17 17 ··· 38 38 ``` 39 39 40 40 See [Configuration](/getting-started/configuration) for all available environment variables. 41 + 42 + ## SQLite in production 43 + 44 + hatk uses SQLite for all data storage. The `DATABASE` environment variable sets the path to the database file. In production, make sure this path points to a persistent volume. 45 + 46 + ### Railway 47 + 48 + [Railway](https://railway.app) is a good fit for hatk apps. To deploy: 49 + 50 + 1. Push your project to a Git repository 51 + 2. Create a new Railway project linked to that repo 52 + 3. Add a persistent volume mounted at `/data` 53 + 4. Set environment variables: 54 + 55 + ``` 56 + DATABASE=/data/hatk.db 57 + RELAY=wss://bsky.network 58 + PORT=3000 59 + ``` 60 + 61 + 5. Set the start command to `hatk start` 62 + 63 + Railway will build and deploy automatically on push. The SQLite database file persists across deploys via the mounted volume.
+4 -4
docs/site/cli/development.md
··· 17 17 2. **Seeds test data** by running `seeds/seed.ts` 18 18 3. **Starts the Hatk server** with file watching for hot reload 19 19 20 - The PDS health is checked at `http://localhost:2583/xrpc/_health` before proceeding. If it doesn't start within 30 seconds, the command exits. 20 + The PDS health is checked at `http://127.0.0.1:2583/xrpc/_health` before proceeding. If it doesn't start within 30 seconds, the command exits. 21 21 22 22 ## `hatk start` 23 23 ··· 27 27 hatk start 28 28 ``` 29 29 30 - Loads `config.yaml`, connects to the configured relay, and begins serving XRPC endpoints. 30 + Loads `hatk.config.ts`, connects to the configured relay, and begins serving XRPC endpoints. 31 31 32 32 ## `hatk seed` 33 33 ··· 47 47 hatk reset 48 48 ``` 49 49 50 - This removes the DuckDB database file and resets the local PDS container, giving you a fresh start. 50 + This removes the SQLite database file and resets the local PDS container, giving you a fresh start. 51 51 52 52 ## `hatk schema` 53 53 54 - Print the DuckDB table schema derived from your lexicon definitions. 54 + Print the SQLite table schema derived from your lexicon definitions. 55 55 56 56 ```bash 57 57 hatk schema
+1 -1
docs/site/cli/index.md
··· 35 35 | `hatk start` | Start the server (production mode) | 36 36 | `hatk seed` | Run seed data against local PDS | 37 37 | `hatk reset` | Wipe database and PDS | 38 - | `hatk schema` | Print DuckDB schema from lexicons | 38 + | `hatk schema` | Print SQLite schema from lexicons | 39 39 40 40 ## Code Quality 41 41
+2 -2
docs/site/cli/scaffold.md
··· 14 14 | Option | Description | 15 15 | ---------- | --------------------------------------------------------- | 16 16 | `<name>` | Project directory name (required) | 17 - | `--svelte` | Include a Svelte frontend with `src/routes` and `src/lib` | 17 + | `--svelte` | Include a SvelteKit frontend with `app/routes` and `app/lib` | 18 18 19 - The command creates the project directory with `config.yaml`, `lexicons/`, `feeds/`, `xrpc/`, `labels/`, `jobs/`, `og/`, `seeds/`, `public/`, `test/`, and the core framework lexicons under `lexicons/dev/hatk/`. 19 + The command creates the project directory with `hatk.config.ts`, `lexicons/`, `server/`, `seeds/`, `test/`, and the core framework lexicons under `lexicons/dev/hatk/`. With `--svelte`, it also creates `app/`, `vite.config.ts`, and `svelte.config.js`. 20 20 21 21 ## `hatk generate` 22 22
+1 -1
docs/site/cli/testing.md
··· 24 24 25 25 ## Writing unit tests 26 26 27 - Unit tests use `createTestContext()` from `hatk/test` to boot an in-memory hatk — lexicons, DuckDB, feeds, and XRPC handlers — with no HTTP server, no PDS, and no indexer. 27 + Unit tests use `createTestContext()` from `hatk/test` to boot an in-memory hatk — lexicons, SQLite, feeds, and XRPC handlers — with no HTTP server, no PDS, and no indexer. 28 28 29 29 ```typescript 30 30 import { describe, test, expect, beforeAll, afterAll } from 'vitest'
+171
docs/site/frontend/data-loading.md
··· 1 + --- 2 + title: Data Loading 3 + description: Load data from your XRPC endpoints in SvelteKit pages using callXrpc and getViewer. 4 + --- 5 + 6 + # Data Loading 7 + 8 + hatk generates a typed `callXrpc()` function that calls your XRPC endpoints. It works in server load functions, universal load functions, and client-side components -- with full type inference for parameters and return values. 9 + 10 + ## `callXrpc()` 11 + 12 + Import `callXrpc` from `$hatk/client`: 13 + 14 + ```typescript 15 + import { callXrpc } from "$hatk/client"; 16 + ``` 17 + 18 + The function signature is: 19 + 20 + ```typescript 21 + async function callXrpc<K extends keyof XrpcSchema>( 22 + nsid: K, 23 + arg?: CallArg<K>, 24 + customFetch?: typeof globalThis.fetch, 25 + ): Promise<OutputOf<K>> 26 + ``` 27 + 28 + The first argument is the XRPC method name (e.g. `"dev.hatk.getFeed"`). TypeScript autocompletes available methods and infers the argument and return types from your lexicons. 29 + 30 + **How it works in different contexts:** 31 + 32 + - **Browser** -- Makes an HTTP request to `/xrpc/{nsid}` on your server 33 + - **Server (SSR)** -- Uses an internal bridge to call your XRPC handlers directly, skipping HTTP entirely 34 + - **Server with `customFetch`** -- Uses the provided fetch function instead of the bridge, which lets SvelteKit deduplicate requests between server and client 35 + 36 + ## Server load functions 37 + 38 + The most common pattern is loading data in `+page.server.ts`. This runs only on the server, so `callXrpc` uses the internal bridge for zero-overhead calls: 39 + 40 + ```typescript 41 + // app/routes/+page.server.ts 42 + import { callXrpc } from "$hatk/client"; 43 + import type { PageServerLoad } from "./$types"; 44 + 45 + export const load: PageServerLoad = async () => { 46 + const feed = await callXrpc("dev.hatk.getFeed", { 47 + feed: "recent", 48 + limit: 30, 49 + }); 50 + return { 51 + items: feed.items ?? [], 52 + cursor: feed.cursor, 53 + }; 54 + }; 55 + ``` 56 + 57 + The returned data is available in your Svelte component via `$props()`: 58 + 59 + ```svelte 60 + <script lang="ts"> 61 + import type { PageData } from './$types' 62 + let { data }: { data: PageData } = $props() 63 + </script> 64 + 65 + {#each data.items as item} 66 + <p>{item.status}</p> 67 + {/each} 68 + ``` 69 + 70 + ## Universal load functions 71 + 72 + Universal load functions (`+page.ts`) run on both server and client. Pass SvelteKit's `fetch` as the third argument to `callXrpc` so SvelteKit can deduplicate the request -- on the server it calls your endpoint directly, and on the client it reuses the serialized response instead of making a second HTTP request: 73 + 74 + ```typescript 75 + // app/routes/+page.ts 76 + import { callXrpc } from "$hatk/client"; 77 + import type { PageLoad } from "./$types"; 78 + 79 + export const load: PageLoad = async ({ fetch }) => { 80 + const feed = await callXrpc( 81 + "dev.hatk.getFeed", 82 + { feed: "recent", limit: 30 }, 83 + fetch, // SvelteKit's fetch for deduplication 84 + ); 85 + return { 86 + items: feed.items ?? [], 87 + cursor: feed.cursor, 88 + }; 89 + }; 90 + ``` 91 + 92 + ### When to use `customFetch` 93 + 94 + Pass SvelteKit's `fetch` as the third argument whenever you call `callXrpc` in a universal load function (`+page.ts` or `+layout.ts`). This tells `callXrpc` to skip the internal server bridge and use SvelteKit's fetch instead, which handles request deduplication between server rendering and client hydration. 95 + 96 + You don't need `customFetch` in `+page.server.ts` files -- those only run on the server, where the bridge is faster. 97 + 98 + ## Client-side data loading 99 + 100 + You can also call `callXrpc` directly in components for client-side fetching, such as infinite scroll: 101 + 102 + ```svelte 103 + <script lang="ts"> 104 + import { callXrpc } from '$hatk/client' 105 + 106 + let items = $state(data.items) 107 + let cursor = $state(data.cursor) 108 + let loadingMore = $state(false) 109 + 110 + async function loadMore() { 111 + if (!cursor || loadingMore) return 112 + loadingMore = true 113 + try { 114 + const res = await callXrpc('dev.hatk.getFeed', { 115 + feed: 'recent', 116 + limit: 30, 117 + cursor, 118 + }) 119 + items = [...items, ...res.items] 120 + cursor = res.cursor 121 + } finally { 122 + loadingMore = false 123 + } 124 + } 125 + </script> 126 + ``` 127 + 128 + When called from the browser, `callXrpc` makes a standard HTTP request to your `/xrpc/{nsid}` endpoint. 129 + 130 + ## Identifying the current user 131 + 132 + ### `parseViewer()` in layout loads 133 + 134 + Use `parseViewer()` in your root layout to read the session cookie and make the current user available to all pages: 135 + 136 + ```typescript 137 + // app/routes/+layout.server.ts 138 + import { parseViewer } from "$hatk/client"; 139 + import type { LayoutServerLoad } from "./$types"; 140 + 141 + export const load: LayoutServerLoad = async ({ cookies }) => { 142 + const viewer = await parseViewer(cookies); 143 + return { viewer }; 144 + }; 145 + ``` 146 + 147 + `parseViewer` decrypts the session cookie and returns `{ did, handle? }` for authenticated users, or `null` for anonymous visitors. The result flows into every page's `data.viewer`. 148 + 149 + ### `getViewer()` in server functions 150 + 151 + Use `getViewer()` to access the current user in remote functions and other server-side code that runs within a request: 152 + 153 + ```typescript 154 + import { getViewer } from "$hatk/client"; 155 + 156 + const viewer = await getViewer(); 157 + if (!viewer) throw new Error("Not authenticated"); 158 + // viewer.did is the user's DID 159 + ``` 160 + 161 + `getViewer()` reads the viewer that was set by `parseViewer` earlier in the request lifecycle. It returns `{ did }` or `null`. 162 + 163 + ## Types from `$hatk/client` 164 + 165 + The generated client re-exports all view and record types from your lexicons: 166 + 167 + ```typescript 168 + import type { StatusView, ProfileView } from "$hatk/client"; 169 + ``` 170 + 171 + These types are derived from your lexicon definitions, so they stay in sync when you change a lexicon and run `hatk generate types`.
+218
docs/site/frontend/mutations.md
··· 1 + --- 2 + title: Mutations 3 + description: Create, update, and delete records from your frontend using remote functions and callXrpc. 4 + --- 5 + 6 + # Mutations 7 + 8 + hatk provides two patterns for mutations from the frontend: **remote functions** for server-side logic callable from components, and **`callXrpc`** for direct API calls. Remote functions are the recommended approach -- they run on the server but are imported and called like normal functions. 9 + 10 + ## Remote functions 11 + 12 + Remote functions use SvelteKit's experimental remote functions feature. You define server-side functions in `.remote.ts` files, then import and call them from your components as if they were local. 13 + 14 + ### Defining remote functions 15 + 16 + Create a `.remote.ts` file in your routes directory: 17 + 18 + ```typescript 19 + // app/routes/status.remote.ts 20 + import { command } from "$app/server"; 21 + import { callXrpc, getViewer } from "$hatk/client"; 22 + 23 + export const createStatus = command("unchecked", async (emoji: string) => { 24 + const viewer = await getViewer(); 25 + if (!viewer) throw new Error("Not authenticated"); 26 + return callXrpc("dev.hatk.createRecord", { 27 + collection: "xyz.statusphere.status" as const, 28 + repo: viewer.did, 29 + record: { status: emoji, createdAt: new Date().toISOString() }, 30 + }); 31 + }); 32 + 33 + export const deleteStatus = command("unchecked", async (rkey: string) => { 34 + const viewer = await getViewer(); 35 + if (!viewer) throw new Error("Not authenticated"); 36 + return callXrpc("dev.hatk.deleteRecord", { 37 + collection: "xyz.statusphere.status" as const, 38 + rkey, 39 + }); 40 + }); 41 + ``` 42 + 43 + Key points: 44 + - `command` comes from SvelteKit's `$app/server` -- it marks a function as a server-only remote function 45 + - `"unchecked"` is the validation mode (SvelteKit experimental API) 46 + - `getViewer()` reads the current user from the session 47 + - `callXrpc("dev.hatk.createRecord", ...)` and `callXrpc("dev.hatk.deleteRecord", ...)` are typed calls to hatk's built-in record management endpoints 48 + 49 + ### Calling remote functions from components 50 + 51 + Import remote functions directly in your Svelte components: 52 + 53 + ```svelte 54 + <script lang="ts"> 55 + import { 56 + createStatus as serverCreateStatus, 57 + deleteStatus as serverDeleteStatus, 58 + } from './status.remote' 59 + 60 + async function handleCreate(emoji: string) { 61 + const res = await serverCreateStatus(emoji) 62 + // res.uri contains the AT URI of the created record 63 + } 64 + 65 + async function handleDelete(uri: string) { 66 + const rkey = uri.split('/').pop()! 67 + await serverDeleteStatus(rkey) 68 + } 69 + </script> 70 + ``` 71 + 72 + Even though these functions run on the server, you call them like any async function. SvelteKit handles the serialization and transport automatically. 73 + 74 + ### Enabling remote functions 75 + 76 + Remote functions require two settings in `svelte.config.js`: 77 + 78 + ```javascript 79 + // svelte.config.js 80 + export default { 81 + compilerOptions: { 82 + experimental: { 83 + async: true, 84 + }, 85 + }, 86 + kit: { 87 + experimental: { 88 + remoteFunctions: true, 89 + }, 90 + }, 91 + }; 92 + ``` 93 + 94 + ## Record mutations with `callXrpc` 95 + 96 + hatk generates three built-in procedures for managing records: 97 + 98 + | Method | Purpose | 99 + |---|---| 100 + | `dev.hatk.createRecord` | Create a new record in a collection | 101 + | `dev.hatk.deleteRecord` | Delete a record by collection and rkey | 102 + | `dev.hatk.putRecord` | Create or update a record at a specific rkey | 103 + 104 + ### Creating a record 105 + 106 + ```typescript 107 + import { callXrpc } from "$hatk/client"; 108 + 109 + const result = await callXrpc("dev.hatk.createRecord", { 110 + collection: "xyz.statusphere.status" as const, 111 + repo: viewer.did, 112 + record: { status: "🚀", createdAt: new Date().toISOString() }, 113 + }); 114 + // result.uri — the AT URI of the new record 115 + // result.cid — the content hash 116 + ``` 117 + 118 + The `collection` field uses `as const` so TypeScript narrows the `record` type to match that collection's schema. If your lexicon says `status` is required, you'll get a type error if you omit it. 119 + 120 + ### Deleting a record 121 + 122 + ```typescript 123 + await callXrpc("dev.hatk.deleteRecord", { 124 + collection: "xyz.statusphere.status" as const, 125 + rkey: "3abc123", 126 + }); 127 + ``` 128 + 129 + The `rkey` is the last segment of the record's AT URI. For example, if the URI is `at://did:plc:abc/xyz.statusphere.status/3abc123`, the rkey is `3abc123`. 130 + 131 + ### Updating a record 132 + 133 + ```typescript 134 + await callXrpc("dev.hatk.putRecord", { 135 + collection: "xyz.statusphere.status" as const, 136 + rkey: "3abc123", 137 + record: { status: "☕", createdAt: new Date().toISOString() }, 138 + }); 139 + ``` 140 + 141 + `putRecord` writes a record at a specific rkey, creating it if it doesn't exist or replacing it if it does. 142 + 143 + ## Optimistic UI 144 + 145 + For a responsive feel, update the UI before the server responds and roll back on failure. Here's the pattern from the statusphere template: 146 + 147 + ```svelte 148 + <script lang="ts"> 149 + import { callXrpc } from '$hatk/client' 150 + import { createStatus as serverCreateStatus } from './status.remote' 151 + import type { StatusView } from '$hatk/client' 152 + 153 + let { data } = $props() 154 + let items = $state(data.items as StatusView[]) 155 + let isMutating = $state(false) 156 + 157 + async function createStatus(emoji: string) { 158 + if (isMutating) return 159 + const did = data.viewer!.did 160 + 161 + // 1. Insert optimistic item immediately 162 + const optimisticItem: StatusView = { 163 + uri: `at://${did}/xyz.statusphere.status/optimistic-${Date.now()}`, 164 + status: emoji, 165 + createdAt: new Date().toISOString(), 166 + author: { did, handle: did }, 167 + } 168 + items = [optimisticItem, ...items] 169 + isMutating = true 170 + 171 + try { 172 + // 2. Call the server 173 + const res = await serverCreateStatus(emoji) 174 + // 3. Replace optimistic item with real URI 175 + items = items.map(i => 176 + i.uri === optimisticItem.uri 177 + ? { ...optimisticItem, uri: res.uri! } 178 + : i 179 + ) 180 + } catch { 181 + // 4. Roll back on failure 182 + items = items.filter(i => i.uri !== optimisticItem.uri) 183 + } finally { 184 + isMutating = false 185 + } 186 + } 187 + </script> 188 + ``` 189 + 190 + The same pattern works for deletes -- remove the item from the list immediately, then restore it if the server call fails: 191 + 192 + ```svelte 193 + <script lang="ts"> 194 + async function deleteStatus(uri: string) { 195 + if (isMutating) return 196 + const removed = items.find(i => i.uri === uri) 197 + items = items.filter(i => i.uri !== uri) 198 + isMutating = true 199 + 200 + try { 201 + await serverDeleteStatus(uri.split('/').pop()!) 202 + } catch { 203 + if (removed) items = [removed, ...items] 204 + } finally { 205 + isMutating = false 206 + } 207 + } 208 + </script> 209 + ``` 210 + 211 + ## When to use remote functions vs. `callXrpc` 212 + 213 + | Use case | Approach | 214 + |---|---| 215 + | Mutations that need auth checks | Remote functions -- call `getViewer()` server-side | 216 + | Multi-step server logic | Remote functions -- keep it in one server round-trip | 217 + | Simple reads from components | `callXrpc` directly -- no server function needed | 218 + | Client-side infinite scroll | `callXrpc` directly in the component |
+133
docs/site/frontend/setup.md
··· 1 + --- 2 + title: Frontend Setup 3 + description: How hatk integrates with SvelteKit — the Vite plugin, generated files, and import aliases. 4 + --- 5 + 6 + # Frontend Setup 7 + 8 + hatk uses SvelteKit for its frontend. The integration is handled by a Vite plugin that generates typed client code from your lexicons, so your components get autocomplete and type checking for every API call. 9 + 10 + ## Vite plugin 11 + 12 + Add the `hatk()` plugin to your `vite.config.ts` alongside `sveltekit()`: 13 + 14 + ```typescript 15 + // vite.config.ts 16 + import { sveltekit } from "@sveltejs/kit/vite"; 17 + import { hatk } from "@hatk/hatk/vite-plugin"; 18 + import { defineConfig } from "vite-plus"; 19 + 20 + export default defineConfig({ 21 + plugins: [hatk(), sveltekit()], 22 + }); 23 + ``` 24 + 25 + The `hatk()` plugin watches your lexicons and regenerates the typed client files when they change during development. It also sets up the server-side bridge that lets `callXrpc()` work in both server and client contexts. 26 + 27 + ## Generated files 28 + 29 + When you run `hatk dev` or `hatk generate types`, hatk produces two files in your project root: 30 + 31 + | File | Purpose | 32 + |---|---| 33 + | `hatk.generated.ts` | Server-side types, helpers, and lexicon definitions. Exports `defineQuery`, `defineProcedure`, `defineFeed`, `callXrpc` (server variant), and all your record/view types. | 34 + | `hatk.generated.client.ts` | Client-safe subset. Exports `callXrpc` (browser variant), `getViewer`, `login`, `logout`, `parseViewer`, and re-exports types from the server file without pulling in server-only dependencies. | 35 + 36 + These files are auto-generated -- don't edit them. Add them to your lint/format ignore patterns: 37 + 38 + ```typescript 39 + // vite.config.ts 40 + export default defineConfig({ 41 + // ... 42 + lint: { 43 + ignorePatterns: ["hatk.generated.ts", "hatk.generated.client.ts"], 44 + }, 45 + fmt: { 46 + ignorePatterns: ["hatk.generated.ts", "hatk.generated.client.ts"], 47 + }, 48 + }); 49 + ``` 50 + 51 + ## Import aliases 52 + 53 + SvelteKit aliases map `$hatk` and `$hatk/client` to the generated files. These are configured in `svelte.config.js`: 54 + 55 + ```javascript 56 + // svelte.config.js 57 + export default { 58 + kit: { 59 + alias: { 60 + $hatk: "./hatk.generated.ts", 61 + "$hatk/client": "./hatk.generated.client.ts", 62 + }, 63 + }, 64 + }; 65 + ``` 66 + 67 + **When to use which:** 68 + 69 + - **`$hatk`** -- Use in server-only code: XRPC handler files, seeds, feeds, hooks. Contains server-side `callXrpc` that talks directly to your XRPC layer without HTTP. 70 + - **`$hatk/client`** -- Use in components, `+page.server.ts`, `+layout.server.ts`, `.remote.ts` files, and anywhere that might run in the browser. Contains the client `callXrpc` that routes through HTTP on the client and uses a server bridge during SSR. 71 + 72 + The rule is simple: if the file can be imported by a Svelte component (even indirectly), use `$hatk/client`. 73 + 74 + ## The `app/` directory 75 + 76 + hatk projects use `app/` instead of `src/` for the SvelteKit source directory: 77 + 78 + ```javascript 79 + // svelte.config.js 80 + export default { 81 + kit: { 82 + files: { 83 + src: "app", 84 + }, 85 + }, 86 + }; 87 + ``` 88 + 89 + Your routes, components, and lib code live under `app/`: 90 + 91 + ``` 92 + app/ 93 + routes/ 94 + +page.svelte 95 + +page.server.ts 96 + +layout.server.ts 97 + lib/ 98 + components/ 99 + ``` 100 + 101 + This is a convention, not a requirement -- but all hatk templates use it and the CLI scaffolding expects it. 102 + 103 + ## Experimental SvelteKit features 104 + 105 + hatk templates enable two experimental SvelteKit features: 106 + 107 + ```javascript 108 + // svelte.config.js 109 + export default { 110 + compilerOptions: { 111 + experimental: { 112 + async: true, // Async Svelte 5 components 113 + }, 114 + }, 115 + kit: { 116 + experimental: { 117 + remoteFunctions: true, // Server functions callable from components 118 + }, 119 + }, 120 + }; 121 + ``` 122 + 123 + The `remoteFunctions` feature powers the `.remote.ts` pattern described in the [mutations guide](./mutations). 124 + 125 + ## Regenerating types 126 + 127 + After adding or changing lexicons, regenerate the typed files: 128 + 129 + ```bash 130 + npx hatk generate types 131 + ``` 132 + 133 + This updates both `hatk.generated.ts` and `hatk.generated.client.ts` with new types, XRPC schema entries, and helper functions matching your lexicons. During `hatk dev`, this happens automatically when lexicon files change.
+84 -94
docs/site/getting-started/configuration.md
··· 1 1 --- 2 2 title: Configuration 3 - description: Configure your Hatk project with hatk.config.ts. 3 + description: Configure your hatk project with hatk.config.ts. 4 4 --- 5 5 6 - ## Overview 6 + hatk is configured through `hatk.config.ts` at the project root. The `defineConfig` helper provides type safety and autocompletion. 7 7 8 - Hatk is configured through `hatk.config.ts` at the project root. The `defineConfig` helper provides type safety and autocompletion. Most options can be overridden with environment variables. 8 + ## Minimal example 9 9 10 - ## Complete example 10 + Most projects only need a few options. Here is a minimal config that works for local development: 11 11 12 12 ```typescript 13 13 import { defineConfig } from '@hatk/hatk/config' 14 14 15 15 export default defineConfig({ 16 - relay: 'ws://localhost:2583', 17 - plc: 'http://localhost:2582', 18 16 port: 3000, 19 17 database: 'data/hatk.db', 20 - publicDir: './public', 21 - admins: [], 22 - 23 - backfill: { 24 - parallelism: 10, 25 - fetchTimeout: 300, 26 - maxRetries: 5, 27 - fullNetwork: false, 28 - }, 29 - 30 - ftsRebuildInterval: 500, 31 - 32 18 oauth: { 33 - issuer: 'http://127.0.0.1:3000', 34 - scopes: ['atproto'], 35 19 clients: [ 36 20 { 37 - client_id: 'http://localhost', 21 + client_id: 'http://127.0.0.1:3000/oauth-client-metadata.json', 38 22 client_name: 'My App', 39 23 redirect_uris: ['http://127.0.0.1:3000/oauth/callback'], 40 24 }, ··· 43 27 }) 44 28 ``` 45 29 46 - ## Options 30 + ## Production example 47 31 48 - ### `relay` 32 + A real-world config that switches between local and production settings: 49 33 50 - WebSocket URL for the AT Protocol firehose relay. 34 + ```typescript 35 + import { defineConfig } from '@hatk/hatk/config' 51 36 52 - - **Default:** `ws://localhost:2583` 53 - - **Env:** `RELAY` 37 + const isProd = process.env.NODE_ENV === 'production' 38 + const prodDomain = process.env.RAILWAY_PUBLIC_DOMAIN 54 39 55 - In production, point this to a relay like `wss://bsky.network`. 56 - 57 - ### `plc` 58 - 59 - PLC directory URL for DID resolution. 60 - 61 - - **Default:** `https://plc.directory` 62 - - **Env:** `DID_PLC_URL` 63 - 64 - ### `port` 65 - 66 - HTTP port for the XRPC server. 67 - 68 - - **Default:** `3000` 69 - - **Env:** `PORT` 70 - 71 - ### `database` 72 - 73 - DuckDB file path. Resolved relative to the config file directory. 74 - 75 - - **Default:** `:memory:` 76 - - **Env:** `DATABASE` 77 - 78 - ### `publicDir` 79 - 80 - Directory for static files. Set to `null` to disable static file serving. 81 - 82 - - **Default:** `./public` 83 - 84 - ### `collections` 40 + export default defineConfig({ 41 + relay: isProd ? 'wss://bsky.network' : 'ws://localhost:2583', 42 + plc: isProd ? 'https://plc.directory' : 'http://localhost:2582', 43 + port: 3000, 44 + database: isProd ? '/data/hatk.db' : 'data/hatk.db', 45 + backfill: { 46 + parallelism: 5, 47 + signalCollections: ['xyz.statusphere.status'], 48 + }, 49 + oauth: { 50 + issuer: isProd && prodDomain ? `https://${prodDomain}` : undefined, 51 + scopes: ['atproto'], 52 + clients: [ 53 + ...(prodDomain 54 + ? [{ 55 + client_id: `https://${prodDomain}/oauth-client-metadata.json`, 56 + client_name: 'My App', 57 + redirect_uris: [`https://${prodDomain}/oauth/callback`], 58 + }] 59 + : []), 60 + { 61 + client_id: 'http://127.0.0.1:3000/oauth-client-metadata.json', 62 + client_name: 'My App', 63 + redirect_uris: ['http://127.0.0.1:3000/oauth/callback'], 64 + }, 65 + ], 66 + }, 67 + }) 68 + ``` 85 69 86 - Array of collection NSIDs to index. If empty, collections are auto-derived from your lexicon record definitions. 70 + ## Server options 87 71 88 - ### `admins` 72 + | Option | Type | Default | Env | Description | 73 + | --- | --- | --- | --- | --- | 74 + | `relay` | `string` | `'ws://localhost:2583'` | `RELAY` | WebSocket URL for the AT Protocol firehose relay. Use `wss://bsky.network` in production. | 75 + | `plc` | `string` | `'https://plc.directory'` | `DID_PLC_URL` | PLC directory URL for DID resolution. Use `http://localhost:2582` for local dev. | 76 + | `port` | `number` | `3000` | `PORT` | HTTP port for the hatk backend server. | 77 + | `publicDir` | `string \| null` | `'./public'` | -- | Directory for static files. Set to `null` to disable static file serving. | 78 + | `collections` | `string[]` | `[]` | -- | Collection NSIDs to index. If empty, auto-derived from your lexicon record definitions. | 79 + | `admins` | `string[]` | `[]` | `ADMINS` | DIDs allowed to access `/admin/*` endpoints. Env var is comma-separated. | 89 80 90 - DIDs allowed to access `/admin/*` endpoints. 81 + ## Database options 91 82 92 - - **Default:** `[]` 93 - - **Env:** `ADMINS` (comma-separated) 83 + | Option | Type | Default | Env | Description | 84 + | --- | --- | --- | --- | --- | 85 + | `database` | `string` | `':memory:'` | `DATABASE` | SQLite database file path, resolved relative to the config file. Use an absolute path in production (e.g., `/data/hatk.db`). | 94 86 95 - ## Backfill 87 + ## Backfill options 96 88 97 - Controls how the server backfills historical data from the network. 89 + The `backfill` object controls how the server catches up on historical data from the AT Protocol network. 98 90 99 - | Option | Default | Env | Description | 100 - | ------------------- | ------- | ------------------------ | ----------------------------------------------------------------------- | 101 - | `parallelism` | `5` | `BACKFILL_PARALLELISM` | Concurrent repo fetches | 102 - | `fetchTimeout` | `300` | `BACKFILL_FETCH_TIMEOUT` | Timeout per repo (seconds) | 103 - | `maxRetries` | `5` | `BACKFILL_MAX_RETRIES` | Max retry attempts for failed repos | 104 - | `fullNetwork` | `false` | `BACKFILL_FULL_NETWORK` | Backfill the entire network | 105 - | `repos` | — | `BACKFILL_REPOS` | Pin specific DIDs to backfill (comma-separated) | 106 - | `signalCollections` | — | — | Collections that trigger backfill (defaults to top-level `collections`) | 91 + | Option | Type | Default | Env | Description | 92 + | --- | --- | --- | --- | --- | 93 + | `backfill.parallelism` | `number` | `3` | `BACKFILL_PARALLELISM` | Number of concurrent repo fetches. | 94 + | `backfill.fetchTimeout` | `number` | `300` | `BACKFILL_FETCH_TIMEOUT` | Timeout per repo fetch in seconds. | 95 + | `backfill.maxRetries` | `number` | `5` | `BACKFILL_MAX_RETRIES` | Max retry attempts for failed repo fetches. | 96 + | `backfill.fullNetwork` | `boolean` | `false` | `BACKFILL_FULL_NETWORK` | Backfill the entire network (not just repos that interact with your collections). | 97 + | `backfill.repos` | `string[]` | -- | `BACKFILL_REPOS` | Pin specific DIDs to always backfill. Env var is comma-separated. | 98 + | `backfill.signalCollections` | `string[]` | -- | -- | Collections that trigger a backfill when a new record appears. Defaults to your top-level `collections`. | 107 99 108 100 ## Full-text search 109 101 110 - ### `ftsRebuildInterval` 111 - 112 - Rebuild the FTS index every N writes. Lower values mean fresher search results but more CPU usage. 113 - 114 - - **Default:** `500` 115 - - **Env:** `FTS_REBUILD_INTERVAL` 116 - 117 - ## OAuth 118 - 119 - Configure OAuth for authenticated endpoints. Set to `null` (or omit) to disable. 102 + | Option | Type | Default | Env | Description | 103 + | --- | --- | --- | --- | --- | 104 + | `ftsRebuildInterval` | `number` | `5000` | `FTS_REBUILD_INTERVAL` | Rebuild the FTS index every N writes. Lower values mean fresher search results but more CPU usage. | 120 105 121 - ### `oauth.issuer` 106 + ## OAuth options 122 107 123 - The OAuth issuer URL. Typically your server's public URL. 108 + The `oauth` object configures AT Protocol OAuth for authenticated endpoints. Set to `null` or omit entirely to disable auth. 124 109 125 - - **Env:** `OAUTH_ISSUER` 110 + | Option | Type | Default | Env | Description | 111 + | --- | --- | --- | --- | --- | 112 + | `oauth.issuer` | `string` | `'http://127.0.0.1:{port}'` | `OAUTH_ISSUER` | The OAuth issuer URL. Typically your server's public URL. | 113 + | `oauth.scopes` | `string[]` | `['atproto']` | -- | OAuth scopes to request. Use [granular scopes](https://atproto.com/specs/oauth#scopes) to limit access (e.g., `'repo:xyz.statusphere.status?action=create&action=delete'`). | 114 + | `oauth.clients` | `OAuthClientConfig[]` | `[]` | -- | Allowed OAuth clients. Each entry needs `client_id`, `client_name`, and `redirect_uris`. | 115 + | `oauth.cookieName` | `string` | `'__hatk_session'` | -- | Name of the session cookie. | 126 116 127 - ### `oauth.scopes` 117 + ### OAuth client fields 128 118 129 - OAuth scopes to request. Defaults to `['atproto']`. 119 + Each entry in `oauth.clients` has: 130 120 131 - ### `oauth.clients` 121 + | Field | Type | Required | Description | 122 + | --- | --- | --- | --- | 123 + | `client_id` | `string` | Yes | Client identifier URL (points to your OAuth client metadata JSON). | 124 + | `client_name` | `string` | Yes | Human-readable name shown during the authorization flow. | 125 + | `redirect_uris` | `string[]` | Yes | Allowed redirect URIs after authorization. | 126 + | `scope` | `string` | No | Scope override for this specific client. | 132 127 133 - Array of allowed OAuth clients. Each client needs: 128 + ## Environment variable overrides 134 129 135 - | Field | Description | 136 - | --------------- | ----------------------- | 137 - | `client_id` | Client identifier URL | 138 - | `client_name` | Human-readable name | 139 - | `redirect_uris` | Allowed redirect URIs | 140 - | `scope` | Optional scope override | 130 + Every option that lists an **Env** column can be set via environment variables. Environment variables take precedence over values in `hatk.config.ts`. This is useful for production deployments where you set secrets and infrastructure-specific values through your hosting platform's environment configuration.
+97 -165
docs/site/getting-started/project-structure.md
··· 1 1 --- 2 2 title: Project Structure 3 - description: Understand the files and directories in a Hatk project. 3 + description: Understand the files and directories in a hatk project. 4 4 --- 5 5 6 - After running `hatk new`, your project looks like this: 6 + After running `npx hatk new`, your project looks like this: 7 7 8 8 ``` 9 9 my-app/ 10 - ├── config.yaml 11 - ├── package.json 12 - ├── docker-compose.yml 13 - ├── Dockerfile 14 - ├── hatk.generated.ts 15 - ├── lexicons/ 16 - ├── feeds/ 17 - ├── xrpc/ 18 - ├── og/ 19 - ├── hooks/ 20 - ├── labels/ 10 + ├── app/ # SvelteKit frontend 11 + │ ├── app.html # HTML shell 12 + │ ├── app.css # Global styles 13 + │ ├── lib/ # Shared utilities 14 + │ └── routes/ # SvelteKit routes 15 + │ ├── +layout.svelte # Root layout 16 + │ ├── +layout.server.ts # Server-side layout data 17 + │ ├── +page.svelte # Home page 18 + │ └── oauth/callback/ 19 + │ └── +page.svelte # OAuth redirect target 20 + ├── server/ # Backend logic 21 + │ ├── recent.ts # Feed generator 22 + │ ├── get-profile.ts # XRPC query handler 23 + │ └── on-login.ts # Lifecycle hook 24 + ├── lexicons/ # AT Protocol schemas 25 + │ ├── xyz/statusphere/ 26 + │ │ ├── status.json # Custom record type 27 + │ │ └── getProfile.json # Custom query endpoint 28 + │ └── app/bsky/actor/ 29 + │ └── profile.json # Bluesky profile (to index) 21 30 ├── seeds/ 22 - ├── setup/ 23 - ├── jobs/ 24 - ├── public/ 25 - └── test/ 26 - ├── feeds/ 27 - ├── xrpc/ 28 - ├── integration/ 29 - ├── browser/ 30 - └── fixtures/ 31 + │ └── seed.ts # Test fixture data 32 + ├── test/ 33 + │ ├── feeds/ # Feed unit tests 34 + │ ├── xrpc/ # XRPC handler tests 35 + │ ├── browser/ # Playwright browser tests 36 + │ └── fixtures/ # Test data (YAML files) 37 + ├── db/ 38 + │ └── schema.sql # Custom SQL migrations 39 + ├── hatk.config.ts # Project configuration 40 + ├── hatk.generated.ts # Generated types (server) 41 + ├── hatk.generated.client.ts # Generated types (client) 42 + ├── vite.config.ts # Vite + SvelteKit config 43 + ├── svelte.config.js # SvelteKit adapter config 44 + ├── docker-compose.yml # Local PDS for development 45 + ├── Dockerfile # Production container build 46 + ├── tsconfig.json # TypeScript config (app) 47 + └── tsconfig.server.json # TypeScript config (server) 31 48 ``` 32 49 33 - With `--svelte`, you also get `src/`, `svelte.config.js`, and `vite.config.ts` for a SvelteKit frontend. 34 - 35 50 --- 36 51 37 - ## Configuration 52 + ## `app/` -- SvelteKit frontend 38 53 39 - ### `config.yaml` 54 + The `app/` directory is a standard SvelteKit application. Routes live in `app/routes/`, shared code goes in `app/lib/`. The `svelte.config.js` maps `app/` as the SvelteKit source directory (instead of the default `src/`). 40 55 41 - Main configuration — relay URL, database path, port, OAuth settings, backfill options, and more. See [Configuration](/getting-started/configuration) for all options. 56 + Auth helpers for login, logout, and reading the current viewer are imported from `$hatk/client`. Data fetching uses `callXrpc()` from the same import to call your backend's typed endpoints. 42 57 43 - ### `package.json` 58 + See the [Frontend section](/frontend/setup) for details on routing, data loading, and mutations. 44 59 45 - Standard Node.js manifest. The scaffold sets up scripts for `dev`, `test`, and `build`. 60 + ## `server/` -- backend logic 46 61 47 - ### `docker-compose.yml` 62 + The `server/` directory contains all your backend code. hatk auto-discovers files in this directory and registers them based on their exports: 48 63 49 - Spins up a local PDS and PLC directory for development. Used automatically by `hatk dev`. 64 + - **Feeds** -- files that export `defineFeed()` become feed generators, queryable via `dev.hatk.getFeed` 65 + - **XRPC handlers** -- files that export `defineQuery()` or `defineProcedure()` become typed API endpoints 66 + - **Hooks** -- files named `on-login.ts` fire after OAuth authentication 67 + - **OG images** -- files in `server/og/` generate dynamic OpenGraph images via satori 68 + - **Setup scripts** -- files in `server/setup/` run at boot time for custom migrations or data imports 69 + - **Labels** -- files in `server/labels/` define content moderation rules 50 70 51 - ### `Dockerfile` 71 + Files prefixed with `_` (like `_helpers.ts`) are ignored by auto-discovery, so use that convention for shared utilities. 52 72 53 - Production container build. Copies your project, installs dependencies, and runs the hatk server. 73 + See the guides for [Feeds](/guides/feeds), [XRPC Handlers](/guides/xrpc-handlers), [Hooks](/guides/hooks), [OpenGraph](/guides/opengraph), and [Labels](/guides/labels). 54 74 55 - ### `hatk.generated.ts` 75 + ## `lexicons/` -- AT Protocol schemas 56 76 57 - Auto-generated TypeScript types derived from your lexicon schemas. Provides typed helpers like `defineFeed()`, `defineQuery()`, `defineProcedure()`, `seed()`, and a `views` object for constructing view responses. Regenerate with: 58 - 59 - ```bash 60 - npx hatk generate types 61 - ``` 62 - 63 - --- 64 - 65 - ## Core directories 66 - 67 - :::note 68 - Files prefixed with `_` (e.g., `_helpers.ts`) are ignored by the framework's auto-discovery in all convention directories (`feeds/`, `xrpc/`, `og/`, `labels/`, `hooks/`, `setup/`). Use this for shared utilities, helper functions, or types that shouldn't be registered as handlers. 69 - ::: 70 - 71 - ### `lexicons/` 72 - 73 - JSON schema files following the [AT Protocol Lexicon](https://atproto.com/specs/lexicon) format, organized by namespace: 77 + Lexicons are JSON schemas that define your data model. Think of them like Prisma models, but for the AT Protocol -- they describe records (data types stored in user repositories), queries (GET endpoints), and procedures (POST endpoints). 74 78 75 79 ``` 76 80 lexicons/ 77 - ├── fm/teal/alpha/feed/play.json 78 - ├── dev/hatk/unspecced/getPlay.json 79 - └── app/bsky/actor/profile.json 80 - ``` 81 - 82 - Lexicons define: 83 - 84 - - **Records** — data types stored in user repositories 85 - - **Queries** — read-only GET endpoints 86 - - **Procedures** — write POST endpoints 87 - 88 - They drive automatic DuckDB table creation and TypeScript type generation. 89 - 90 - ### `feeds/` 91 - 92 - Feed generators using `defineFeed()`. Each file exports a feed with a `generate` function that queries DuckDB and returns record URIs, plus an optional `hydrate` function for enrichment. See [Feeds guide](/guides/feeds). 93 - 94 - ``` 95 - feeds/ 96 - └── latest.ts 97 - ``` 98 - 99 - ### `xrpc/` 100 - 101 - Custom XRPC endpoint handlers organized by namespace path. Each file uses `defineQuery()` or `defineProcedure()` and must have a matching lexicon. See [XRPC Handlers guide](/guides/xrpc-handlers). 102 - 103 - ``` 104 - xrpc/ 105 - └── dev/hatk/unspecced/ 106 - └── getPlay.ts 81 + ├── xyz/statusphere/ 82 + │ ├── status.json # Record: a status emoji 83 + │ └── getProfile.json # Query: get a user's profile 84 + └── app/bsky/actor/ 85 + └── profile.json # Bluesky's profile record (to index) 107 86 ``` 108 87 109 - ### `og/` 88 + Lexicons drive two things automatically: 89 + 1. **Database tables** -- hatk creates SQLite tables for each record type 90 + 2. **TypeScript types** -- `hatk generate types` produces typed helpers in `hatk.generated.ts` 110 91 111 - OpenGraph image routes using satori for server-side rendering. Each file exports a `path` and `generate()` function that returns a virtual DOM element. See [OpenGraph guide](/guides/opengraph). 92 + Organized by reverse-DNS namespace (e.g., `xyz/statusphere/status.json` for the `xyz.statusphere.status` collection). 112 93 113 - ``` 114 - og/ 115 - └── play.ts 116 - ``` 94 + ## `seeds/` -- test fixture data 117 95 118 - ### `hooks/` 96 + The `seeds/seed.ts` file creates test accounts and records against the local PDS during development. It runs automatically when you `npm run dev`, or manually with `hatk seed`. 119 97 120 - Lifecycle hooks. Currently supports `on-login.ts`, which fires after a user authenticates via OAuth. The hook receives the user's `did` and an `ensureRepo` helper. See [Hooks guide](/guides/hooks). 98 + Seeds use the AT Protocol API to create real data -- accounts, records, follows -- so your app has something to display during development without connecting to the live network. 121 99 122 - ``` 123 - hooks/ 124 - └── on-login.ts 125 - ``` 100 + See the [Seeds guide](/guides/seeds). 126 101 127 - ### `labels/` 102 + ## `test/` -- tests 128 103 129 - Label definitions for content moderation. Each file exports a label definition with `evaluate()` logic. See [Labels guide](/guides/labels). 104 + Tests are organized by type: 130 105 131 - ``` 132 - labels/ 133 - └── explicit.ts 134 - ``` 106 + | Directory | Purpose | Runner | 107 + | ---------------- | ------------------------------ | ---------- | 108 + | `test/feeds/` | Feed generator unit tests | Vitest | 109 + | `test/xrpc/` | XRPC handler tests | Vitest | 110 + | `test/browser/` | End-to-end browser tests | Playwright | 111 + | `test/fixtures/` | Shared YAML test data | -- | 135 112 136 - ### `seeds/` 113 + Run tests with `npm run test` (unit/integration) or `npm run test:browser` (Playwright). 137 114 138 - Test fixture data. Contains a `seed.ts` that creates accounts and records against the local PDS during development. Run with `hatk seed` or automatically during `hatk dev`. See [Seeds guide](/guides/seeds). 115 + ## `hatk.config.ts` -- configuration 139 116 140 - ``` 141 - seeds/ 142 - └── seed.ts 143 - ``` 117 + The main configuration file. Controls the relay connection, database path, backfill settings, OAuth, and more. Uses `defineConfig()` for type safety. 144 118 145 - ### `setup/` 119 + See the [Configuration page](/getting-started/configuration) for all options. 146 120 147 - Boot-time setup scripts that run after the database initializes but before the server starts. Each file exports a handler function that receives a context with `db.query` and `db.run`: 121 + ## `hatk.generated.ts` / `hatk.generated.client.ts` 148 122 149 - ```typescript 150 - export default async function (ctx) { 151 - await ctx.db.run(`CREATE TABLE IF NOT EXISTS my_cache (key TEXT, value TEXT)`) 152 - } 153 - ``` 123 + Auto-generated TypeScript derived from your lexicon schemas. Do not edit these files directly. 154 124 155 - Setup scripts are skipped in test contexts. 125 + - **`hatk.generated.ts`** -- server-side types and helpers: `defineFeed()`, `defineQuery()`, `defineProcedure()`, `seed()`, typed record interfaces, and view types 126 + - **`hatk.generated.client.ts`** -- client-safe subset: `callXrpc()` for typed API calls, `login()`/`logout()`/`parseViewer()` for auth, plus re-exported types 156 127 157 - ### `jobs/` 128 + Regenerate both with: 158 129 159 - Reserved for periodic background tasks. This directory is scaffolded but does not have runtime support yet. 160 - 161 - ### `public/` 162 - 163 - Static files served by the server at the root path. Place your frontend HTML, CSS, and JavaScript here. 164 - 165 - ``` 166 - public/ 167 - └── index.html 130 + ```bash 131 + npx hatk generate types 168 132 ``` 169 133 170 - Hatk provides a default `robots.txt` that allows all crawlers. To customize it, add your own `public/robots.txt` — it will take priority over the default. 134 + ## `vite.config.ts` / `svelte.config.js` 171 135 172 - ### `src/` (with `--svelte`) 136 + - **`vite.config.ts`** -- loads the `hatk()` Vite plugin (which proxies API routes to the hatk backend during development) and the `sveltekit()` plugin. Also configures test includes/excludes. 137 + - **`svelte.config.js`** -- sets `app/` as the SvelteKit source directory and configures the `$hatk` and `$hatk/client` import aliases that point to the generated files. 173 138 174 - When scaffolded with `--svelte`, a SvelteKit frontend replaces the plain `public/index.html`: 139 + ## `db/schema.sql` 175 140 176 - ``` 177 - src/ 178 - ├── app.html # HTML shell 179 - ├── app.css # Global styles 180 - ├── error.html # Error fallback 181 - ├── lib/ 182 - │ ├── api.ts # Typed XRPC client instance 183 - │ ├── auth.ts # OAuth helpers (login, logout, viewerDid) 184 - │ └── query.ts # TanStack Query client 185 - └── routes/ 186 - ├── +layout.svelte # Root layout (OAuth init + QueryProvider) 187 - ├── +page.svelte # Home page 188 - ├── +error.svelte # Error page 189 - └── oauth/callback/ 190 - └── +page.svelte # OAuth redirect target 191 - ``` 141 + Optional custom SQL that runs after hatk creates its auto-generated tables. Use this for custom indexes, views, or tables that go beyond what lexicons define. 192 142 193 - The Vite plugin proxies API routes to the hatk backend during development. In production, `vite build` compiles to `public/`. See [Frontend guide](/guides/frontend) for details. 194 - 195 - ### `test/` 196 - 197 - Test files organized by type: 198 - 199 - | Directory | Purpose | 200 - | ------------------- | ---------------------------- | 201 - | `test/feeds/` | Feed generator unit tests | 202 - | `test/xrpc/` | XRPC handler tests | 203 - | `test/integration/` | End-to-end integration tests | 204 - | `test/browser/` | Playwright browser tests | 205 - | `test/fixtures/` | Shared test data and helpers | 206 - 207 - Run all tests with `hatk test`. See [Testing](/cli/testing) for details. 143 + ## `docker-compose.yml` / `Dockerfile` 208 144 209 - --- 145 + - **`docker-compose.yml`** -- runs a local PDS and PLC directory for development. Started automatically by `npm run dev`. 146 + - **`Dockerfile`** -- production container build for deployment. 210 147 211 148 ## Runtime files 212 149 213 - ### `data/` 214 - 215 - Created at runtime. Contains the DuckDB database file (`hatk.db`) and its write-ahead log. 150 + The `data/` directory is created at runtime and contains the SQLite database (`hatk.db`). It is gitignored by default. 216 151 217 152 ``` 218 153 data/ 219 - ├── hatk.db 220 - └── hatk.db.wal 154 + └── hatk.db 221 155 ``` 222 - 223 - This directory is gitignored by default.
+28 -38
docs/site/getting-started/quickstart.md
··· 1 1 --- 2 2 title: Quickstart 3 - description: Create and run your first Hatk project. 3 + description: Create and run your first hatk project in under two minutes. 4 4 --- 5 5 6 6 ## Prerequisites 7 7 8 - - **Node.js 25+** 9 - - **Docker** (for the local PDS) 8 + - **Node.js 22+** — check with `node --version` 9 + - **Docker** — needed to run the local PDS (Personal Data Server) during development 10 10 11 11 ## Create a new project 12 12 ··· 14 14 npx hatk new my-app 15 15 ``` 16 16 17 - For a project with a Svelte frontend: 18 - 19 - ```bash 20 - npx hatk new my-app --svelte 21 - ``` 22 - 23 - This scaffolds a project with the following structure: 17 + This scaffolds a full-stack project with a SvelteKit frontend, example feed, seed data, and everything wired together. The generated project includes: 24 18 25 19 ``` 26 20 my-app/ 27 - ├── config.yaml 28 - ├── lexicons/ 29 - ├── feeds/ 30 - ├── xrpc/ 31 - ├── og/ 32 - ├── labels/ 33 - ├── jobs/ 34 - ├── seeds/ 35 - ├── setup/ 36 - ├── public/ 37 - ├── test/ 38 - │ ├── feeds/ 39 - │ ├── xrpc/ 40 - │ ├── integration/ 41 - │ ├── browser/ 42 - │ └── fixtures/ 43 - └── hatk.generated.ts 21 + ├── app/ # SvelteKit frontend (routes, components, styles) 22 + ├── server/ # Backend logic (feeds, XRPC handlers, hooks) 23 + ├── lexicons/ # AT Protocol schemas for your data types 24 + ├── seeds/ # Test fixture data for local development 25 + ├── hatk.config.ts # Project configuration 26 + ├── hatk.generated.ts # Auto-generated types from your lexicons 27 + ├── vite.config.ts # Vite + SvelteKit config 28 + └── docker-compose.yml # Local PDS for development 44 29 ``` 45 30 46 31 ## Start the dev server 47 32 48 33 ```bash 49 34 cd my-app 50 - npx hatk dev 35 + npm run dev 51 36 ``` 52 37 53 - This starts: 38 + This does three things automatically: 54 39 55 - 1. A local PDS via Docker 56 - 2. Runs your seed data 57 - 3. Starts the Hatk server with file watching 40 + 1. Starts a local PDS via Docker (your own mini AT Protocol server) 41 + 2. Runs seed data to create test accounts and records 42 + 3. Starts the hatk backend and SvelteKit dev server with hot reload 43 + 44 + ## See it running 45 + 46 + Open `http://127.0.0.1:3000` in your browser. You should see the starter app running with seeded data. Both the frontend and API are served on the same port. 58 47 59 - ## Make your first request 48 + Try hitting an endpoint directly: 60 49 61 50 ```bash 62 - curl http://localhost:3000/xrpc/dev.hatk.describeCollections 51 + curl http://127.0.0.1:3000/xrpc/dev.hatk.describeCollections 63 52 ``` 64 53 65 - This returns the collections your server is indexing, derived from your lexicon schemas. 54 + This returns the data collections your server is indexing, derived from your lexicon schemas. 66 55 67 56 ## Next steps 68 57 69 - - [Project Structure](/getting-started/project-structure) — understand each file and directory 70 - - [Configuration](/getting-started/configuration) — customize `config.yaml` 71 - - [CLI Reference](/cli/) — all available commands 58 + - [Project Structure](/getting-started/project-structure) -- understand each file and directory 59 + - [Configuration](/getting-started/configuration) -- customize `hatk.config.ts` 60 + - [Feeds](/guides/feeds) -- build custom feed algorithms 61 + - [Auth](/guides/auth) -- add login and authenticated actions
-98
docs/site/guides/api-client.md
··· 1 - --- 2 - title: API Client 3 - description: Type-safe XRPC client for calling your hatk endpoints. 4 - --- 5 - 6 - The `hatk/xrpc-client` package provides a typed client for calling XRPC endpoints from the browser or any JavaScript environment. 7 - 8 - ## Setup 9 - 10 - ```typescript 11 - // src/lib/api.ts 12 - import { createClient } from 'hatk/xrpc-client' 13 - import type { XrpcSchema } from '$hatk' 14 - import { getAuthFetch } from './auth' 15 - 16 - export const api = createClient<XrpcSchema>(typeof window !== 'undefined' ? window.location.origin : '', { 17 - fetch: (url, opts) => getAuthFetch()(url as string, opts), 18 - }) 19 - ``` 20 - 21 - The `XrpcSchema` type is generated from your lexicons and provides full type safety for endpoint names, parameters, inputs, and outputs. 22 - 23 - ## Methods 24 - 25 - ### `api.query(nsid, params?)` 26 - 27 - Call a query (GET) endpoint: 28 - 29 - ```typescript 30 - const result = await api.query('xyz.statusphere.getStatuses', { 31 - limit: 30, 32 - }) 33 - // result is typed: { statuses: StatusView[], cursor?: string } 34 - ``` 35 - 36 - Parameters are type-checked against the lexicon's parameter definitions. 37 - 38 - ### `api.call(nsid, input?, params?)` 39 - 40 - Call a procedure (POST) endpoint with an optional request body: 41 - 42 - ```typescript 43 - await api.call('dev.hatk.createRecord', { 44 - collection: 'xyz.statusphere.status', 45 - repo: viewerDid()!, 46 - record: { status: '🚀', createdAt: new Date().toISOString() }, 47 - }) 48 - ``` 49 - 50 - The `input` argument is typed from the lexicon's input schema. 51 - 52 - ### `api.upload(nsid, data, contentType)` 53 - 54 - Upload binary data (e.g., images): 55 - 56 - ```typescript 57 - const result = await api.upload('dev.hatk.uploadBlob', file, 'image/jpeg') 58 - // result: { blob: { ref: { $link: '...' }, ... } } 59 - ``` 60 - 61 - ## Authentication 62 - 63 - The client accepts a custom `fetch` function. Wire it to the OAuth client's authenticated fetch to automatically add DPoP headers: 64 - 65 - ```typescript 66 - import { getAuthFetch } from './auth' 67 - 68 - const api = createClient<XrpcSchema>(origin, { 69 - fetch: (url, opts) => getAuthFetch()(url as string, opts), 70 - }) 71 - ``` 72 - 73 - When the user is logged in, requests include `Authorization: DPoP <token>` and a `DPoP` proof header. When logged out, it falls back to plain `fetch`. 74 - 75 - See [OAuth](/guides/oauth) for setting up `getAuthFetch()`. 76 - 77 - ## Error handling 78 - 79 - Failed requests throw an `Error` with the XRPC error message: 80 - 81 - ```typescript 82 - try { 83 - await api.query('dev.hatk.unspecced.getPlay', { uri }) 84 - } catch (err) { 85 - // err.message: "NotFound" or "Missing required parameter: uri" 86 - } 87 - ``` 88 - 89 - ## Without SvelteKit 90 - 91 - The client works in any JavaScript environment. Just pass a base URL: 92 - 93 - ```typescript 94 - import { createClient } from 'hatk/xrpc-client' 95 - import type { XrpcSchema } from './hatk.generated.ts' 96 - 97 - const api = createClient<XrpcSchema>('https://my-appview.example.com') 98 - ```
+280
docs/site/guides/auth.md
··· 1 + --- 2 + title: Auth 3 + description: Add authentication to your hatk app with OAuth and session cookies. 4 + --- 5 + 6 + # Auth 7 + 8 + hatk handles AT Protocol OAuth entirely server-side. When a user signs in, hatk runs the OAuth flow with their PDS (Personal Data Server), then stores the session in an encrypted cookie. Your frontend just calls `login(handle)` and `logout()` -- no token management, no client-side OAuth libraries. 9 + 10 + ## How it works 11 + 12 + 1. User enters their handle, frontend calls `login(handle)` 13 + 2. Browser redirects to the user's PDS for authorization 14 + 3. PDS redirects back to your server, which completes the token exchange 15 + 4. Server sets an encrypted session cookie 16 + 5. On subsequent requests, `parseViewer(cookies)` reads the cookie to identify the user 17 + 18 + ## Configuration 19 + 20 + Enable OAuth in `hatk.config.ts` by adding an `oauth` section: 21 + 22 + ```typescript 23 + // hatk.config.ts 24 + import { defineConfig } from "@hatk/hatk/config"; 25 + 26 + export default defineConfig({ 27 + // ... other config 28 + oauth: { 29 + issuer: "https://my-app.example.com", 30 + scopes: ["atproto"], 31 + clients: [ 32 + { 33 + client_id: "https://my-app.example.com/oauth-client-metadata.json", 34 + client_name: "my-hatk-app", 35 + scope: "atproto", 36 + redirect_uris: ["https://my-app.example.com/oauth/callback"], 37 + }, 38 + // Local development client 39 + { 40 + client_id: "http://127.0.0.1:3000/oauth-client-metadata.json", 41 + client_name: "my-hatk-app", 42 + scope: "atproto", 43 + redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], 44 + }, 45 + ], 46 + }, 47 + }); 48 + ``` 49 + 50 + ### OAuth config options 51 + 52 + | Field | Description | 53 + | --------- | -------------------------------------------------------------------------- | 54 + | `issuer` | Your app's public URL. Used for OAuth metadata discovery. Optional in dev. | 55 + | `scopes` | Array of OAuth scopes your app needs | 56 + | `clients` | Array of OAuth client configurations (one per environment) | 57 + 58 + ### Scopes 59 + 60 + Scopes control what the token can do: 61 + 62 + - `atproto` -- base AT Protocol access (read-only) 63 + - `repo:<collection>?action=create&action=delete` -- write access to a specific collection 64 + 65 + For example, an app that creates and deletes status records: 66 + 67 + ```typescript 68 + scopes: ["atproto repo:xyz.statusphere.status?action=create&action=delete"], 69 + ``` 70 + 71 + ## Frontend auth 72 + 73 + `login` and `logout` are generated helpers available from `$hatk/client`. They handle the full OAuth redirect flow. 74 + 75 + ### Login 76 + 77 + `login(handle)` redirects the browser to the user's PDS for authorization. After the user approves, they're redirected back to your app with an active session: 78 + 79 + ```typescript 80 + import { login } from "$hatk/client"; 81 + 82 + await login("alice.bsky.social"); 83 + // Browser redirects to PDS → user approves → redirects back with session cookie 84 + ``` 85 + 86 + ### Logout 87 + 88 + `logout()` clears the session cookie: 89 + 90 + ```typescript 91 + import { logout } from "$hatk/client"; 92 + 93 + await logout(); 94 + ``` 95 + 96 + ### Login form example 97 + 98 + A minimal Svelte login form using `login` and `logout`: 99 + 100 + ```svelte 101 + <script lang="ts"> 102 + import { login, logout } from '$hatk/client' 103 + import { invalidateAll } from '$app/navigation' 104 + 105 + let { data } = $props() 106 + let handle = $state('') 107 + let loading = $state(false) 108 + let error = $state('') 109 + 110 + async function handleLogin() { 111 + if (!handle.trim()) return 112 + loading = true 113 + error = '' 114 + try { 115 + await login(handle) 116 + } catch (e: any) { 117 + error = e.message 118 + } finally { 119 + loading = false 120 + } 121 + } 122 + 123 + async function handleLogout() { 124 + await logout() 125 + await invalidateAll() 126 + } 127 + </script> 128 + 129 + {#if data.viewer} 130 + <p>Signed in as <code>{data.viewer.did}</code></p> 131 + <button onclick={handleLogout}>Sign out</button> 132 + {:else} 133 + <form onsubmit={handleLogin}> 134 + <input type="text" bind:value={handle} placeholder="your.handle" /> 135 + <button type="submit" disabled={loading}> 136 + {loading ? 'Signing in...' : 'Sign in'} 137 + </button> 138 + </form> 139 + {#if error} 140 + <p style="color: red;">{error}</p> 141 + {/if} 142 + {/if} 143 + ``` 144 + 145 + ## Server-side auth 146 + 147 + ### `parseViewer` in layouts 148 + 149 + Use `parseViewer(cookies)` in your `+layout.server.ts` to read the session cookie and pass the viewer to all routes: 150 + 151 + ```typescript 152 + // app/routes/+layout.server.ts 153 + import { parseViewer } from "$hatk/client"; 154 + import type { LayoutServerLoad } from "./$types"; 155 + 156 + export const load: LayoutServerLoad = async ({ cookies }) => { 157 + const viewer = await parseViewer(cookies); 158 + return { viewer }; 159 + }; 160 + ``` 161 + 162 + `parseViewer` returns `{ did: string; handle?: string }` if a valid session exists, or `null` if the user is not signed in. The `viewer` is then available in `data.viewer` on every page through SvelteKit's layout data flow. 163 + 164 + ### `ctx.viewer` in handlers 165 + 166 + In XRPC handlers and feed generators, the authenticated user is available as `ctx.viewer`: 167 + 168 + ```typescript 169 + import { defineQuery } from "$hatk"; 170 + 171 + export default defineQuery("my.app.getPrivateData", async (ctx) => { 172 + if (!ctx.viewer) throw new Error("Authentication required"); 173 + 174 + const { did } = ctx.viewer; 175 + const rows = await ctx.db.query( 176 + `SELECT * FROM my_table WHERE did = $1`, 177 + [did], 178 + ); 179 + 180 + return ctx.ok({ items: rows }); 181 + }); 182 + ``` 183 + 184 + `ctx.viewer` is the same `{ did: string }` shape in both XRPC handlers and feed `generate`/`hydrate` functions. 185 + 186 + ## Complete example 187 + 188 + Here's the full auth flow from config to login form to protected data. 189 + 190 + ### 1. Configure OAuth 191 + 192 + ```typescript 193 + // hatk.config.ts 194 + import { defineConfig } from "@hatk/hatk/config"; 195 + 196 + export default defineConfig({ 197 + // ... 198 + oauth: { 199 + scopes: ["atproto repo:xyz.statusphere.status?action=create&action=delete"], 200 + clients: [ 201 + { 202 + client_id: "http://127.0.0.1:3000/oauth-client-metadata.json", 203 + client_name: "statusphere", 204 + scope: "atproto repo:xyz.statusphere.status?action=create&action=delete", 205 + redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], 206 + }, 207 + ], 208 + }, 209 + }); 210 + ``` 211 + 212 + ### 2. Parse the viewer in your layout 213 + 214 + ```typescript 215 + // app/routes/+layout.server.ts 216 + import { parseViewer } from "$hatk/client"; 217 + import type { LayoutServerLoad } from "./$types"; 218 + 219 + export const load: LayoutServerLoad = async ({ cookies }) => { 220 + const viewer = await parseViewer(cookies); 221 + return { viewer }; 222 + }; 223 + ``` 224 + 225 + ### 3. Build the login form 226 + 227 + ```svelte 228 + <!-- app/routes/+page.svelte --> 229 + <script lang="ts"> 230 + import { login, logout } from '$hatk/client' 231 + import { invalidateAll } from '$app/navigation' 232 + 233 + let { data } = $props() 234 + let handle = $state('') 235 + 236 + async function doLogin() { 237 + if (!handle.trim()) return 238 + try { 239 + await login(handle.trim()) 240 + } catch { 241 + alert('Handle not found. Check spelling and try again.') 242 + } 243 + } 244 + 245 + async function doLogout() { 246 + await logout() 247 + await invalidateAll() 248 + } 249 + </script> 250 + 251 + {#if data.viewer} 252 + <p>Signed in as {data.viewer.did}</p> 253 + <button onclick={doLogout}>Sign out</button> 254 + 255 + <!-- Authenticated content here --> 256 + {:else} 257 + <form onsubmit={(e) => { e.preventDefault(); doLogin() }}> 258 + <input bind:value={handle} placeholder="Enter your handle (e.g. alice.bsky.social)" /> 259 + <button type="submit">Sign in</button> 260 + </form> 261 + {/if} 262 + ``` 263 + 264 + ### 4. Protect a server endpoint 265 + 266 + ```typescript 267 + // server/xrpc/getMyData.ts 268 + import { defineQuery } from "$hatk"; 269 + 270 + export default defineQuery("my.app.getMyData", async (ctx) => { 271 + if (!ctx.viewer) throw new Error("Authentication required"); 272 + 273 + const rows = await ctx.db.query( 274 + `SELECT * FROM "xyz.statusphere.status" WHERE did = $1`, 275 + [ctx.viewer.did], 276 + ); 277 + 278 + return ctx.ok({ items: rows }); 279 + }); 280 + ```
-142
docs/site/guides/deployment.md
··· 1 - --- 2 - title: Deployment 3 - description: Deploy hatk apps to Railway with SQLite, volumes, and production debugging. 4 - --- 5 - 6 - ## Railway 7 - 8 - ### Dockerfile 9 - 10 - Include `sqlite3` in the container for production debugging: 11 - 12 - ```dockerfile 13 - FROM node:25-slim 14 - RUN apt-get update && apt-get install -y --no-install-recommends \ 15 - ca-certificates xz-utils sqlite3 \ 16 - && rm -rf /var/lib/apt/lists/* 17 - WORKDIR /app 18 - COPY package.json package-lock.json ./ 19 - RUN npm ci 20 - COPY . . 21 - RUN npx vp build 22 - RUN npm prune --omit=dev 23 - ENV NODE_ENV=production 24 - CMD ["node", "--experimental-strip-types", "node_modules/@hatk/hatk/dist/main.js", "hatk.config.ts"] 25 - ``` 26 - 27 - ### Volume 28 - 29 - Mount a Railway volume at `/data` for the SQLite database: 30 - 31 - ```bash 32 - railway volume create -m /data 33 - ``` 34 - 35 - Set the database path in `hatk.config.ts`: 36 - 37 - ```ts 38 - export default defineConfig({ 39 - databaseEngine: 'sqlite', 40 - database: process.env.NODE_ENV === 'production' ? '/data/app.db' : 'data/app.db', 41 - }) 42 - ``` 43 - 44 - ### Health checks 45 - 46 - If setup scripts run long imports on startup, increase the health check timeout in `railway.toml`: 47 - 48 - ```toml 49 - [deploy] 50 - healthcheckPath = "/_health" 51 - healthcheckTimeout = 600 52 - ``` 53 - 54 - ### SSH debugging 55 - 56 - Railway SSH doesn't reliably support piped stdin or shell metacharacters. Use the base64 script pattern to run queries on prod: 57 - 58 - ```bash 59 - # Write a shell script locally 60 - cat > /tmp/query.sh <<'EOF' 61 - sqlite3 /data/app.db "SELECT COUNT(*) FROM [my.collection];" 62 - EOF 63 - 64 - # Base64 encode and execute via SSH 65 - B64=$(base64 < /tmp/query.sh | tr -d '\n') 66 - railway ssh "sh -c \"echo $B64 | base64 -d | sh\"" 67 - ``` 68 - 69 - For multi-line SQL, use heredocs inside the script: 70 - 71 - ```bash 72 - cat > /tmp/query.sh <<'EOF' 73 - sqlite3 /data/app.db <<'EOSQL' 74 - EXPLAIN QUERY PLAN 75 - SELECT t.uri FROM [my.collection] t 76 - ORDER BY t.some_field DESC LIMIT 50; 77 - EOSQL 78 - EOF 79 - ``` 80 - 81 - Use bracket quoting `[table.name]` instead of double quotes for table names to avoid escaping issues. 82 - 83 - ## SQLite production notes 84 - 85 - ### Custom indexes 86 - 87 - hatk auto-creates indexes on `indexed_at DESC`, `did`, child table `parent_uri`, and child table text columns. For app-specific queries, add custom indexes in a setup script: 88 - 89 - ```ts 90 - // server/setup/create-indexes.ts 91 - import { defineSetup } from '$hatk' 92 - 93 - export default defineSetup(async (ctx) => { 94 - const { db } = ctx 95 - await db.run( 96 - `CREATE INDEX IF NOT EXISTS idx_plays_played_time 97 - ON "fm.teal.alpha.feed.play"(played_time DESC)`, 98 - ) 99 - }) 100 - ``` 101 - 102 - Setup scripts run on every startup. `CREATE INDEX IF NOT EXISTS` makes them idempotent. 103 - 104 - ### Datetime comparisons 105 - 106 - SQLite's `datetime()` returns space-separated format (`2026-03-16 12:00:00`) while hatk stores ISO timestamps with `T` separator (`2026-03-16T12:00:00Z`). String comparison breaks because `T` > space in ASCII. 107 - 108 - ```sql 109 - -- WRONG: matches too many rows 110 - WHERE played_time >= datetime('now', '-4 hours') 111 - 112 - -- CORRECT: generates ISO format 113 - WHERE played_time >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-4 hours') 114 - ``` 115 - 116 - ### Query performance tips 117 - 118 - Use `EXPLAIN QUERY PLAN` via SSH to diagnose slow queries. Watch for: 119 - 120 - - **`SCAN` without index** — add an index on the filtered/ordered column 121 - - **`USE TEMP B-TREE FOR ORDER BY`** — the `ORDER BY` column needs a descending index 122 - - **Large JOINs with DISTINCT** — rewrite as `EXISTS` subqueries so SQLite can walk an index and stop at `LIMIT` 123 - 124 - For expensive aggregation queries (trending lists, category counts), use stale-while-revalidate caching: 125 - 126 - ```ts 127 - let cache: { data: any; expires: number } | null = null 128 - const TTL = 5 * 60 * 1000 129 - 130 - async function refresh(db) { 131 - const rows = await db.query(`...`) 132 - cache = { data: rows, expires: Date.now() + TTL } 133 - return rows 134 - } 135 - 136 - // In handler: 137 - if (cache) { 138 - if (Date.now() >= cache.expires) refresh(db) // background refresh 139 - return ok(cache.data) // serve stale immediately 140 - } 141 - return ok(await refresh(db)) // first request waits 142 - ```
+181 -79
docs/site/guides/feeds.md
··· 1 1 --- 2 2 title: Feeds 3 - description: Define custom feeds with generate and hydrate phases. 3 + description: Define custom feeds that turn your indexed data into timelines. 4 4 --- 5 5 6 - Create feeds in the `feeds/` directory using `defineFeed()`: 6 + # Feeds 7 + 8 + Feeds are custom timelines powered by your indexed data. Each feed queries your SQLite database to produce a list of record URIs, and optionally enriches those results with author profiles and other metadata before returning them to the client. 9 + 10 + ## Defining a feed 11 + 12 + Create a feed file in your `server/` directory using `defineFeed()`: 13 + 14 + ```typescript 15 + // server/recent.ts 16 + import { defineFeed } from "$hatk"; 17 + 18 + export default defineFeed({ 19 + collection: "xyz.statusphere.status", 20 + label: "Recent", 7 21 8 - ```bash 9 - hatk generate feed recent 22 + async generate(ctx) { 23 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 24 + `SELECT uri, cid, indexed_at, created_at FROM "xyz.statusphere.status"`, 25 + { orderBy: "created_at", order: "DESC" }, 26 + ); 27 + 28 + return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); 29 + }, 30 + }); 10 31 ``` 11 32 12 - Each feed has two phases: **generate** (query for record URIs) and **hydrate** (enrich results with additional data). 33 + This feed queries every status record, sorted newest-first, with automatic cursor-based pagination. The framework resolves the returned URIs into full records before sending them to the client. 13 34 14 35 ## `defineFeed` options 15 36 ··· 17 38 | ------------ | ------------------------------- | -------------------------------------------- | 18 39 | `collection` | Yes (unless `hydrate` provided) | The collection this feed queries | 19 40 | `label` | Yes | Human-readable name shown in `describeFeeds` | 20 - | `view` | No | View definition to use for auto-hydration | 21 41 | `generate` | Yes | Function that returns record URIs | 22 42 | `hydrate` | No | Function that enriches resolved records | 23 43 24 - --- 44 + ## The `generate` function 25 45 26 - ## `generate` 46 + `generate` receives a context object and returns a list of AT URIs plus an optional cursor for pagination. 27 47 28 - Queries DuckDB and returns record URIs with optional cursor-based pagination. 48 + ### Context reference 29 49 30 - ### Context 50 + | Field | Type | Description | 51 + | --------------------- | ------------------------- | ------------------------------------------------------------------- | 52 + | `db.query` | function | Run SQL queries against your SQLite database | 53 + | `params` | `Record<string, string>` | Query string parameters from the request | 54 + | `limit` | number | Requested page size | 55 + | `cursor` | string \| undefined | Pagination cursor from the client | 56 + | `viewer` | `{ did: string }` \| null | The authenticated user, or null | 57 + | `ok` | function | Wraps your return value with type checking | 58 + | `paginate` | function | Run a paginated query (handles cursor, ORDER BY, LIMIT) | 59 + | `packCursor` | function | Encode a `(primary, cid)` pair into an opaque cursor string | 60 + | `unpackCursor` | function | Decode a cursor back into `{ primary, cid }` or null | 61 + | `isTakendown` | function | Check if a DID has been taken down | 62 + | `filterTakendownDids` | function | Filter a list of DIDs, returning those that are taken down | 31 63 32 - | Field | Type | Description | 33 - | --------------------- | ------------------------- | --------------------------------------------------------------------- | 34 - | `db.query` | function | Run SQL queries against DuckDB | 35 - | `params` | `Record<string, string>` | Query string parameters from the request | 36 - | `limit` | number | Requested page size | 37 - | `cursor` | string \| undefined | Pagination cursor from the client | 38 - | `viewer` | `{ did: string }` \| null | The authenticated user, or null | 39 - | `ok` | function | Wraps your return value with type checking | 40 - | `packCursor` | function | Encode a `(primary, cid)` pair into an opaque cursor string | 41 - | `unpackCursor` | function | Decode a cursor back into `{ primary, cid }` or null | 42 - | `isTakendown` | function | Check if a DID has been taken down | 43 - | `filterTakendownDids` | function | Filter a list of DIDs, returning those that are taken down | 44 - | `paginate` | function | Run a paginated query — handles cursor, ORDER BY, LIMIT automatically | 64 + ## Pagination with `ctx.paginate()` 45 65 46 - ### Cursor pagination 66 + `paginate` is the recommended way to handle cursor-based pagination. It takes a SQL query and handles cursor unpacking, `WHERE`/`AND` clause injection, `ORDER BY`, `LIMIT`, `hasMore` detection, and cursor packing automatically. 47 67 48 - `packCursor` and `unpackCursor` implement a standard two-field cursor pattern. Encode a sort value (like `indexed_at`) and the record's `cid` as a tiebreaker: 68 + ### Basic usage 49 69 50 70 ```typescript 51 - // Encode: packCursor(indexed_at, cid) → "MjAyNS0wMS..." 52 - const cursor = packCursor(last.indexed_at, last.cid) 71 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 72 + `SELECT uri, cid, indexed_at FROM "xyz.statusphere.status"`, 73 + ); 74 + ``` 75 + 76 + ### With parameters and custom sort 53 77 54 - // Decode: unpackCursor("MjAyNS0wMS...") → { primary: "2025-01-01T...", cid: "bafyrei..." } 55 - const parsed = unpackCursor(cursor) 78 + Pass SQL parameters and specify which column to sort by: 79 + 80 + ```typescript 81 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 82 + `SELECT p.uri, p.cid, p.played_time 83 + FROM "fm.teal.alpha.feed.play__artists" a 84 + JOIN "fm.teal.alpha.feed.play" p ON p.uri = a.parent_uri 85 + WHERE a.artist_name = $1`, 86 + { params: [artist], orderBy: "p.played_time" }, 87 + ); 56 88 ``` 57 89 58 - ### Example 90 + `paginate` appends cursor conditions, `ORDER BY`, and `LIMIT` to your query. You provide the base `SELECT` and any `WHERE` clauses for filtering; `paginate` adds the rest. 91 + 92 + ### Using the viewer 93 + 94 + Feeds can use `ctx.viewer` to personalize results. For example, a "following" feed that shows records from accounts the viewer follows: 59 95 60 96 ```typescript 61 - import { defineFeed } from '../hatk.generated.ts' 97 + import { defineFeed } from "$hatk"; 62 98 63 99 export default defineFeed({ 64 - collection: 'fm.teal.alpha.feed.play', 65 - label: 'Recent', 100 + collection: "fm.teal.alpha.feed.play", 101 + label: "Following", 66 102 67 103 async generate(ctx) { 104 + const actorDid = ctx.params.actor || ctx.viewer?.did; 105 + if (!actorDid) { 106 + return ctx.ok({ uris: [], cursor: undefined }); 107 + } 108 + 68 109 const { rows, cursor } = await ctx.paginate<{ uri: string }>( 69 - `SELECT uri, cid, indexed_at FROM "fm.teal.alpha.feed.play"`, 70 - ) 110 + `SELECT p.uri, p.cid, p.played_time 111 + FROM "fm.teal.alpha.feed.play" p 112 + INNER JOIN "app.bsky.graph.follow" f ON f.subject = p.did 113 + WHERE f.did = $1`, 114 + { params: [actorDid], orderBy: "p.played_time" }, 115 + ); 71 116 72 - return ctx.ok({ uris: rows.map((r) => r.uri), cursor }) 117 + return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); 73 118 }, 74 - }) 119 + }); 75 120 ``` 76 121 77 - `paginate` handles cursor unpacking, WHERE/AND injection, ORDER BY, LIMIT, hasMore detection, and cursor packing. Pass options for custom sort columns or user params: 122 + ### Manual cursors 123 + 124 + If you need more control than `paginate` provides, use `packCursor` and `unpackCursor` directly. They implement a two-field cursor pattern using a sort value (like `indexed_at`) and the record's `cid` as a tiebreaker: 78 125 79 126 ```typescript 80 - const { rows, cursor } = await ctx.paginate<{ uri: string }>( 81 - `SELECT p.uri, p.cid, p.played_time 82 - FROM "fm.teal.alpha.feed.play__artists" a 83 - JOIN "fm.teal.alpha.feed.play" p ON p.uri = a.parent_uri 84 - WHERE a.artist_name = $1`, 85 - { params: [artist], orderBy: 'p.played_time' }, 86 - ) 127 + // Encode: packCursor(indexed_at, cid) → "MjAyNS0wMS..." 128 + const cursor = packCursor(last.indexed_at, last.cid); 129 + 130 + // Decode: unpackCursor("MjAyNS0wMS...") → { primary: "2025-01-01T...", cid: "bafyrei..." } 131 + const parsed = unpackCursor(cursor); 87 132 ``` 88 133 89 - --- 134 + ## Hydration 90 135 91 - ## `hydrate` (optional) 136 + The optional `hydrate` function enriches feed results with additional data. After `generate` returns URIs, the framework resolves them into full records, then passes those records to `hydrate`. 92 137 93 - Enriches feed results with additional data after the `generate` phase returns URIs. The framework resolves the URIs into full records, then passes them to your `hydrate` function. 94 - 95 - ### Context 138 + ### Hydrate context reference 96 139 97 140 | Field | Type | Description | 98 141 | ------------ | ------------------------- | --------------------------------------------------------------- | 99 142 | `items` | `Row[]` | The resolved records (each has `uri`, `did`, `handle`, `value`) | 100 143 | `viewer` | `{ did: string }` \| null | The authenticated user, or null | 101 - | `db.query` | function | Run SQL queries against DuckDB | 144 + | `db.query` | function | Run SQL queries against your SQLite database | 102 145 | `getRecords` | function | Fetch records by URI from another collection | 103 146 | `lookup` | function | Look up records by a field value (e.g. profiles by DID) | 104 147 | `count` | function | Count records by field value | 105 148 | `labels` | function | Query labels for a list of URIs | 106 149 | `blobUrl` | function | Resolve a blob reference to a CDN URL | 107 150 108 - ### Example 151 + ### Example with hydration 152 + 153 + This feed queries status records and hydrates each one with the author's profile: 109 154 110 155 ```typescript 111 - import { defineFeed } from '../hatk.generated.ts' 156 + import { defineFeed, views, type Status, type Profile, type HydrateContext } from "$hatk"; 112 157 113 158 export default defineFeed({ 114 - collection: 'fm.teal.alpha.feed.play', 115 - label: 'Recent', 159 + collection: "xyz.statusphere.status", 160 + label: "Recent", 161 + 162 + hydrate: (ctx) => hydrateStatuses(ctx), 116 163 117 164 async generate(ctx) { 118 165 const { rows, cursor } = await ctx.paginate<{ uri: string }>( 119 - `SELECT uri, cid, indexed_at FROM "fm.teal.alpha.feed.play"`, 120 - ) 121 - return ctx.ok({ uris: rows.map((r) => r.uri), cursor }) 166 + `SELECT uri, cid, indexed_at, created_at FROM "xyz.statusphere.status"`, 167 + { orderBy: "created_at", order: "DESC" }, 168 + ); 169 + 170 + return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); 122 171 }, 172 + }); 123 173 124 - async hydrate(ctx) { 125 - // Look up author profiles for all items in one batch 126 - const dids = [...new Set(ctx.items.map((item) => item.did))] 127 - const profiles = await ctx.lookup('app.bsky.actor.profile', 'did', dids) 174 + async function hydrateStatuses(ctx: HydrateContext<Status>) { 175 + const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))]; 176 + const profiles = await ctx.lookup<Profile>("app.bsky.actor.profile", "did", dids); 128 177 129 - return ctx.items.map((item) => { 130 - const author = profiles.get(item.did) 131 - return { 132 - uri: item.uri, 178 + return ctx.items.map((item) => { 179 + const author = profiles.get(item.did); 180 + return views.statusView({ 181 + uri: item.uri, 182 + status: item.value.status, 183 + createdAt: item.value.createdAt, 184 + indexedAt: item.indexed_at, 185 + author: views.profileView({ 133 186 did: item.did, 134 - ...item.value, 135 - author: author 136 - ? { 137 - did: author.did, 138 - handle: author.handle, 139 - displayName: author.value.displayName, 140 - avatar: ctx.blobUrl(author.did, author.value.avatar), 141 - } 142 - : undefined, 143 - } 144 - }) 145 - }, 146 - }) 187 + handle: item.handle || item.did, 188 + displayName: author?.value.displayName, 189 + avatar: author ? ctx.blobUrl(author.did, author.value.avatar, "avatar") : undefined, 190 + }), 191 + }); 192 + }); 193 + } 194 + ``` 195 + 196 + Key patterns: 197 + 198 + - **Batch lookups** — `ctx.lookup()` fetches records for multiple DIDs in one call. Collect unique DIDs first to avoid duplicate queries. 199 + - **`ctx.blobUrl()`** — converts a blob reference (like an avatar) into a CDN URL the client can load. 200 + - **View builders** — `views.statusView()` and `views.profileView()` are generated from your lexicon's view definitions, providing type-safe construction. 201 + 202 + ### Hydration with viewer context 203 + 204 + Hydration can also use `ctx.viewer` to add viewer-specific data like bookmarks: 205 + 206 + ```typescript 207 + async function hydratePlays(ctx: HydrateContext<Play>) { 208 + const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))]; 209 + const profiles = await ctx.lookup<Profile>("app.bsky.actor.profile", "did", dids); 210 + 211 + // Load viewer's bookmarks 212 + const bookmarks = new Map<string, string>(); 213 + if (ctx.viewer?.did && ctx.items.length > 0) { 214 + const rows = await ctx.db.query( 215 + `SELECT subject, uri FROM "community.lexicon.bookmarks.bookmark" WHERE did = $1`, 216 + [ctx.viewer.did], 217 + ); 218 + for (const row of rows as { subject: string; uri: string }[]) { 219 + bookmarks.set(row.subject, row.uri); 220 + } 221 + } 222 + 223 + return ctx.items.map((item) => { 224 + const author = profiles.get(item.did); 225 + return views.playView({ 226 + record: { uri: item.uri, did: item.did, handle: item.handle, ...item.value }, 227 + author: author 228 + ? { 229 + did: author.did, 230 + handle: author.handle, 231 + displayName: author.value.displayName, 232 + avatar: ctx.blobUrl(author.did, author.value.avatar), 233 + } 234 + : undefined, 235 + viewerBookmark: bookmarks.get(item.uri), 236 + }); 237 + }); 238 + } 147 239 ``` 240 + 241 + ## Generating a feed 242 + 243 + Use the CLI to scaffold a new feed file: 244 + 245 + ```bash 246 + hatk generate feed recent 247 + ``` 248 + 249 + This creates the file with the right imports and structure.
-140
docs/site/guides/frontend.md
··· 1 - --- 2 - title: Frontend (SvelteKit) 3 - description: Build a frontend for your hatk server with SvelteKit. 4 - --- 5 - 6 - Scaffold a project with `--svelte` to get a SvelteKit frontend wired to your hatk backend: 7 - 8 - ```bash 9 - hatk new my-app --svelte 10 - ``` 11 - 12 - ## Project layout 13 - 14 - The scaffold creates: 15 - 16 - ``` 17 - src/ 18 - ├── app.html # HTML shell 19 - ├── app.css # Global styles 20 - ├── error.html # Error fallback 21 - ├── lib/ 22 - │ ├── api.ts # Typed XRPC client 23 - │ ├── auth.ts # OAuth helpers 24 - │ └── query.ts # TanStack Query client 25 - └── routes/ 26 - ├── +layout.svelte # Root layout (OAuth init + QueryProvider) 27 - ├── +page.svelte # Home page 28 - ├── +error.svelte # Error page 29 - └── oauth/ 30 - └── callback/ 31 - └── +page.svelte # OAuth redirect target 32 - ``` 33 - 34 - Plus these config files at the root: 35 - 36 - - **`svelte.config.js`** — Static adapter outputting to `public/`, with `$hatk` alias pointing to `hatk.generated.ts` 37 - - **`vite.config.ts`** — SvelteKit + hatk Vite plugin 38 - 39 - ## Vite plugin 40 - 41 - The `hatk()` Vite plugin does two things: 42 - 43 - 1. **Proxies API routes** to the hatk backend (port 3001) during development — `/xrpc`, `/oauth/*`, `/.well-known`, `/og`, `/blob`, and others 44 - 2. **Starts the hatk server** automatically when the Vite dev server launches 45 - 46 - ```typescript 47 - // vite.config.ts 48 - import { sveltekit } from '@sveltejs/kit/vite' 49 - import { hatk } from 'hatk/vite-plugin' 50 - import { defineConfig } from 'vite' 51 - 52 - export default defineConfig({ 53 - plugins: [sveltekit(), hatk()], 54 - }) 55 - ``` 56 - 57 - During development, the Vite dev server runs on port 3000 and proxies API calls to the backend on port 3001. In production, the static build outputs to `public/` and the hatk server serves everything directly on port 3000. 58 - 59 - ## The `$hatk` alias 60 - 61 - `svelte.config.js` defines an alias so you can import generated types cleanly: 62 - 63 - ```typescript 64 - import type { XrpcSchema } from '$hatk' 65 - ``` 66 - 67 - This resolves to `./hatk.generated.ts`, giving you type-safe access to all your lexicon-defined endpoints. 68 - 69 - ## Root layout 70 - 71 - The root layout initializes OAuth and wraps the app in a TanStack Query provider: 72 - 73 - ```svelte 74 - <script lang="ts"> 75 - import { QueryClientProvider } from '@tanstack/svelte-query' 76 - import { queryClient } from '$lib/query' 77 - import { handleCallback, getOAuthClient } from '$lib/auth' 78 - import { onMount } from 'svelte' 79 - import { goto } from '$app/navigation' 80 - 81 - let { children } = $props() 82 - let ready = $state(false) 83 - 84 - onMount(async () => { 85 - const handled = await handleCallback() 86 - if (handled) goto('/', { replaceState: true }) 87 - getOAuthClient() 88 - ready = true 89 - }) 90 - </script> 91 - 92 - {#if ready} 93 - <QueryClientProvider client={queryClient}> 94 - {@render children()} 95 - </QueryClientProvider> 96 - {/if} 97 - ``` 98 - 99 - The layout checks for an OAuth callback on every page load. Once initialized, it renders child routes inside the query provider. 100 - 101 - ## Fetching data 102 - 103 - Use TanStack Svelte Query with the typed API client: 104 - 105 - ```svelte 106 - <script lang="ts"> 107 - import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query' 108 - import { api } from '$lib/api' 109 - import { viewerDid } from '$lib/auth' 110 - 111 - const queryClient = useQueryClient() 112 - 113 - // Read data with api.query() 114 - const statuses = createQuery(() => ({ 115 - queryKey: ['statuses'], 116 - queryFn: () => api.query('xyz.statusphere.getStatuses', { limit: 30 }), 117 - })) 118 - 119 - // Write data with api.call() 120 - const createStatus = createMutation(() => ({ 121 - mutationFn: (emoji: string) => 122 - api.call('dev.hatk.createRecord', { 123 - collection: 'xyz.statusphere.status', 124 - repo: viewerDid()!, 125 - record: { status: emoji, createdAt: new Date().toISOString() }, 126 - }), 127 - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['statuses'] }), 128 - })) 129 - </script> 130 - ``` 131 - 132 - ## Building for production 133 - 134 - The SvelteKit static adapter compiles your frontend to `public/`: 135 - 136 - ```bash 137 - npx vite build 138 - ``` 139 - 140 - The hatk server serves files from `public/` with SPA fallback, so client-side routing works out of the box.
+17 -27
docs/site/guides/hooks.md
··· 1 1 --- 2 2 title: Hooks 3 - description: Lifecycle hooks for customizing Hatk behavior. 3 + description: Run custom logic at key points in the server lifecycle. 4 4 --- 5 5 6 - Hooks let you run custom logic at key points in the Hatk lifecycle. Place hook files in the `hooks/` directory. 6 + Hooks let you run custom logic at key points in the Hatk lifecycle, like when a user logs in via OAuth. Define them with `defineHook()` in the `server/` directory. 7 7 8 8 ## `on-login` 9 9 10 - Runs after a successful OAuth login. Use this to trigger backfill of the user's repository so their data is indexed immediately. 11 - 12 - Create `hooks/on-login.ts`: 10 + The `on-login` hook runs after a successful OAuth login. The most common use is calling `ensureRepo` to backfill the user's data so it's available immediately: 13 11 14 12 ```typescript 15 - export default async function onLogin({ 16 - did, 17 - ensureRepo, 18 - }: { 19 - did: string 20 - ensureRepo: (did: string) => Promise<void> 21 - }) { 13 + // server/on-login.ts 14 + import { defineHook } from '$hatk' 15 + 16 + export default defineHook('on-login', async ({ did, ensureRepo }) => { 22 17 await ensureRepo(did) 23 - } 18 + }) 24 19 ``` 25 20 26 - ### Context 21 + This is three lines, but it's important: without it, a new user's existing records won't appear until the firehose (the AT Protocol's real-time event stream) delivers them. `ensureRepo` fetches the user's repository from their PDS and indexes it right away. 27 22 28 - | Field | Type | Description | 29 - | ------------ | -------- | -------------------------------------------------------- | 30 - | `did` | string | The DID of the user who just logged in | 31 - | `ensureRepo` | function | Marks the user's repo as pending and triggers a backfill | 23 + ## Hook context 32 24 33 - ### How `ensureRepo` works 34 - 35 - Calling `ensureRepo(did)` does two things: 36 - 37 - 1. Sets the repo status to `pending` in the database 38 - 2. Triggers an automatic backfill of that user's repository from their PDS 25 + The `on-login` handler receives: 39 26 40 - This means the first time a user logs in, their existing records are fetched and indexed — so their data is available immediately rather than waiting for new firehose events. 27 + | Field | Type | Description | 28 + | --- | --- | --- | 29 + | `did` | string | The DID (decentralized identifier) of the user who logged in | 30 + | `ensureRepo` | `(did: string) => Promise<void>` | Marks the user's repo as pending and triggers a backfill from their PDS | 41 31 42 - ### Error handling 32 + ## Error handling 43 33 44 - If the hook throws an error, it's logged but does not block the login flow. The user still completes authentication successfully. 34 + If a hook throws, the error is logged but does not block the login flow. The user still completes authentication successfully.
+47 -120
docs/site/guides/labels.md
··· 1 1 --- 2 2 title: Labels 3 - description: Define labels for content moderation and metadata. 3 + description: Apply moderation labels to records as they're indexed. 4 4 --- 5 5 6 - Labels are metadata tags applied to records for moderation, categorization, or informational purposes. They follow the [AT Protocol labeling spec](https://atproto.com/specs/label). 6 + Labels are metadata tags that get applied to records for moderation or categorization. They follow the AT Protocol labeling spec (a standard way for services to annotate content with things like "explicit" or "nsfw"). Hatk evaluates label rules automatically each time a record is indexed. 7 7 8 - ## Defining labels 8 + ## Defining a label 9 9 10 - Create label definitions in the `labels/` directory: 11 - 12 - ```bash 13 - hatk generate label explicit 14 - ``` 15 - 16 - Each label module exports a `definition` describing the label and an `evaluate` function that decides when to apply it. Label rules run automatically each time a record is indexed — if `evaluate` returns label identifiers, they're stored in the `_labels` table. 17 - 18 - Here's an example that marks `fm.teal.alpha.feed.play` records as explicit when the track name contains common explicit content indicators: 10 + Create a file in `server/` that exports `defineLabels()` with a `definition` and an `evaluate` function: 19 11 20 12 ```typescript 21 - import type { LabelRuleContext } from 'hatk/labels' 13 + // server/labels/explicit.ts 14 + import { defineLabels } from '$hatk' 22 15 23 16 const EXPLICIT_PATTERNS = [/\(explicit\)/i, /\[explicit\]/i, /\bexplicit version\b/i] 24 17 25 - export default { 18 + export default defineLabels({ 26 19 definition: { 27 20 identifier: 'explicit', 28 21 severity: 'inform', 29 22 blurs: 'none', 30 23 defaultSetting: 'warn', 31 24 locales: [ 32 - { 33 - lang: 'en', 34 - name: 'Explicit', 35 - description: 'Track contains explicit content', 36 - }, 25 + { lang: 'en', name: 'Explicit', description: 'Track contains explicit content' }, 37 26 ], 38 27 }, 39 28 40 - async evaluate(ctx: LabelRuleContext) { 29 + async evaluate(ctx) { 41 30 if (ctx.record.collection !== 'fm.teal.alpha.feed.play') return [] 42 31 43 32 const trackName = ctx.record.value.trackName || '' ··· 45 34 46 35 return isExplicit ? ['explicit'] : [] 47 36 }, 48 - } 37 + }) 49 38 ``` 50 39 51 - You can also query the database in `evaluate` for more complex rules — for example, checking an external blocklist table: 40 + The `evaluate` function runs for every indexed record. Return an array of label identifier strings to apply, or `[]` to skip. Labels are stored in the `_labels` table automatically. 41 + 42 + ## Evaluate context 43 + 44 + The `evaluate` function receives a context with: 45 + 46 + | Field | Description | 47 + | --- | --- | 48 + | `ctx.db.query(sql, params?)` | Run a SQL query against SQLite | 49 + | `ctx.db.run(sql, params?)` | Execute a SQL statement | 50 + | `ctx.record.uri` | AT URI of the record | 51 + | `ctx.record.cid` | Content hash of the record | 52 + | `ctx.record.did` | DID (decentralized identifier) of the author | 53 + | `ctx.record.collection` | Collection NSID (e.g. `fm.teal.alpha.feed.play`) | 54 + | `ctx.record.value` | The record's fields as an object | 55 + 56 + You can query the database in `evaluate` for more complex rules: 52 57 53 58 ```typescript 54 - async evaluate(ctx: LabelRuleContext) { 59 + async evaluate(ctx) { 55 60 if (ctx.record.collection !== 'fm.teal.alpha.feed.play') return [] 56 61 57 62 const rows = await ctx.db.query( 58 - `SELECT 1 FROM explicit_tracks WHERE isrc = $1 LIMIT 1`, 63 + `SELECT 1 FROM explicit_tracks WHERE isrc = ? LIMIT 1`, 59 64 [ctx.record.value.isrc], 60 65 ) 61 66 ··· 63 68 }, 64 69 ``` 65 70 66 - ## `LabelDefinition` 67 - 68 - | Field | Type | Description | 69 - | ---------------- | ------------------------------------ | ---------------------------------- | 70 - | `identifier` | string | Unique label ID | 71 - | `severity` | `'alert'` \| `'inform'` \| `'none'` | How urgently to surface the label | 72 - | `blurs` | `'media'` \| `'content'` \| `'none'` | What to blur when label is applied | 73 - | `defaultSetting` | `'warn'` \| `'hide'` \| `'ignore'` | Default user-facing behavior | 74 - | `locales` | array | Localized name and description | 75 - 76 - ## Evaluation context 71 + ## Label definition fields 77 72 78 - The `evaluate` function receives a `LabelRuleContext` with: 79 - 80 - | Field | Type | Description | 81 - | ------------------- | -------- | ------------------------------------ | 82 - | `db.query` | function | Run SQL queries against DuckDB | 83 - | `db.run` | function | Execute SQL statements | 84 - | `record.uri` | string | AT URI of the record being evaluated | 85 - | `record.cid` | string | CID of the record | 86 - | `record.did` | string | DID of the record author | 87 - | `record.collection` | string | Collection NSID | 88 - | `record.value` | object | The record's fields | 89 - 90 - Label rules run automatically when records are indexed. Return an array of label identifier strings to apply, or an empty array to skip. 91 - 92 - --- 73 + | Field | Type | Description | 74 + | --- | --- | --- | 75 + | `identifier` | string | Unique label ID | 76 + | `severity` | `'alert'` \| `'inform'` \| `'none'` | How urgently to surface the label | 77 + | `blurs` | `'media'` \| `'content'` \| `'none'` | What to blur when label is applied | 78 + | `defaultSetting` | `'warn'` \| `'hide'` \| `'ignore'` | Default user-facing behavior | 79 + | `locales` | array | Localized name and description | 93 80 94 81 ## Hydrating labels in responses 95 82 96 - Labels are stored in a `_labels` table and can be included in feed and query responses during hydration. The `HydrateContext` provides a `labels()` helper that queries active labels for a set of record URIs. 97 - 98 - ### Using `ctx.labels()` in a hydrate function 83 + Labels stored in `_labels` can be included in feed and query responses. The `ctx.labels()` helper queries active labels for a set of record URIs: 99 84 100 85 ```typescript 101 - import { defineFeed } from '../hatk.generated.ts' 102 - 103 - export default defineFeed({ 104 - collection: 'fm.teal.alpha.feed.play', 105 - label: 'Recent', 106 - 107 - async generate(ctx) { 108 - const rows = await ctx.db.query( 109 - `SELECT uri, cid, indexed_at FROM "fm.teal.alpha.feed.play" 110 - ORDER BY indexed_at DESC LIMIT $1`, 111 - [ctx.limit + 1], 112 - ) 113 - const hasMore = rows.length > ctx.limit 114 - if (hasMore) rows.pop() 115 - const last = rows[rows.length - 1] 116 - return ctx.ok({ 117 - uris: rows.map((r) => r.uri), 118 - cursor: hasMore && last ? ctx.packCursor(last.indexed_at, last.cid) : undefined, 119 - }) 120 - }, 86 + async hydrate(ctx) { 87 + const uris = ctx.items.map((item) => item.uri) 88 + const labelMap = await ctx.labels(uris) 121 89 122 - async hydrate(ctx) { 123 - const uris = ctx.items.map((item) => item.uri) 124 - const labelMap = await ctx.labels(uris) 125 - 126 - return ctx.items.map((item) => ({ 127 - ...item, 128 - labels: labelMap.get(item.uri) || [], 129 - })) 130 - }, 131 - }) 90 + return ctx.items.map((item) => ({ 91 + ...item, 92 + labels: labelMap.get(item.uri) || [], 93 + })) 94 + }, 132 95 ``` 133 96 134 - The `ctx.labels()` method returns a `Map<string, Label[]>` where each label has: 135 - 136 - | Field | Type | Description | 137 - | ----- | -------------- | -------------------------------------- | 138 - | `src` | string | DID of the label creator | 139 - | `uri` | string | AT URI of the labeled resource | 140 - | `val` | string | Label identifier (e.g. `"explicit"`) | 141 - | `neg` | boolean | If true, this negates a previous label | 142 - | `cts` | string | Timestamp when the label was created | 143 - | `exp` | string \| null | Expiration timestamp | 144 - 145 - Only active labels are returned — expired labels and labels that have been negated are automatically filtered out. 146 - 147 - ### Adding labels to a view lexicon 148 - 149 - To include labels in your view's response type, add a field that references `com.atproto.label.defs#label`: 150 - 151 - ```json 152 - { 153 - "playView": { 154 - "type": "object", 155 - "required": ["record"], 156 - "properties": { 157 - "record": { 158 - "type": "ref", 159 - "ref": "fm.teal.alpha.feed.play" 160 - }, 161 - "labels": { 162 - "type": "array", 163 - "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 164 - } 165 - } 166 - } 167 - } 168 - ``` 169 - 170 - This gives you a typed `labels` field on the view. You then populate it in your hydrate function using `ctx.labels()` as shown above. 97 + `ctx.labels()` returns a `Map<string, Label[]>`. Expired and negated labels are automatically filtered out.
-183
docs/site/guides/oauth.md
··· 1 - --- 2 - title: OAuth 3 - description: Authenticate users with AT Protocol OAuth. 4 - --- 5 - 6 - Hatk includes a browser OAuth client (`@hatk/oauth-client`) that implements AT Protocol's DPoP-protected OAuth 2.0 flow with PKCE. 7 - 8 - ## Setup 9 - 10 - Create an auth helper module in your frontend: 11 - 12 - ```typescript 13 - // src/lib/auth.ts 14 - import { OAuthClient } from '@hatk/oauth-client' 15 - 16 - let client: OAuthClient | null = null 17 - 18 - export function getOAuthClient(): OAuthClient { 19 - if (!client) { 20 - client = new OAuthClient({ 21 - server: window.location.origin, 22 - clientId: window.location.origin + '/oauth-client-metadata.json', 23 - redirectUri: window.location.origin + '/oauth/callback', 24 - scope: 'atproto repo:xyz.statusphere.status?action=create&action=delete', 25 - }) 26 - } 27 - return client 28 - } 29 - ``` 30 - 31 - ### Constructor options 32 - 33 - | Option | Default | Description | 34 - | ------------- | ------------------------ | ----------------------------------------------------- | 35 - | `server` | — | Hatk server URL | 36 - | `clientId` | `window.location.origin` | OAuth client ID (must match client metadata endpoint) | 37 - | `redirectUri` | current page URL | Where to redirect after authorization | 38 - | `scope` | `'atproto'` | OAuth scopes to request | 39 - 40 - ### Scopes 41 - 42 - The `scope` string controls what the token can do: 43 - 44 - - `atproto` — base AT Protocol access 45 - - `repo:<collection>?action=create&action=delete` — write access to a specific collection 46 - 47 - Example requesting create and delete access to status records: 48 - 49 - ``` 50 - atproto repo:xyz.statusphere.status?action=create&action=delete 51 - ``` 52 - 53 - ## Login 54 - 55 - Call `login()` with a handle to start the OAuth flow. This redirects the browser to the user's PDS for authorization: 56 - 57 - ```typescript 58 - export async function login(handle: string): Promise<void> { 59 - await getOAuthClient().login(handle) 60 - } 61 - ``` 62 - 63 - The flow uses Pushed Authorization Requests (PAR) with PKCE and DPoP proofs. 64 - 65 - ## Handling the callback 66 - 67 - After the user approves, the PDS redirects back to your `redirectUri`. Handle this in your root layout: 68 - 69 - ```typescript 70 - export async function handleCallback(): Promise<boolean> { 71 - return getOAuthClient().handleCallback() 72 - } 73 - ``` 74 - 75 - Returns `true` if a callback was processed, `false` if no OAuth params were present. The scaffold's root layout calls this on mount: 76 - 77 - ```svelte 78 - onMount(async () => { 79 - const handled = await handleCallback() 80 - if (handled) goto('/', { replaceState: true }) 81 - getOAuthClient() 82 - ready = true 83 - }) 84 - ``` 85 - 86 - ## Checking auth state 87 - 88 - ```typescript 89 - export function isLoggedIn(): boolean { 90 - return !!client?.isLoggedIn 91 - } 92 - 93 - export function viewerDid(): string | null { 94 - return client?.did ?? null 95 - } 96 - ``` 97 - 98 - - `isLoggedIn` — checks for a valid access token and stored DID 99 - - `did` — returns the authenticated user's DID 100 - 101 - ## Authenticated fetch 102 - 103 - The OAuth client provides a `fetch` method that automatically adds DPoP proof and Authorization headers: 104 - 105 - ```typescript 106 - export function getAuthFetch(): typeof fetch { 107 - const c = client 108 - if (c?.isLoggedIn) { 109 - return (url: string | URL | Request, opts?: RequestInit) => 110 - c.fetch(typeof url === 'string' ? url : url.toString(), opts) 111 - } 112 - return fetch.bind(globalThis) 113 - } 114 - ``` 115 - 116 - Pass this to the [API client](/guides/api-client) so all requests are authenticated when a user is logged in: 117 - 118 - ```typescript 119 - export const api = createClient<XrpcSchema>(origin, { 120 - fetch: (url, opts) => getAuthFetch()(url as string, opts), 121 - }) 122 - ``` 123 - 124 - ## Token refresh 125 - 126 - Tokens are automatically refreshed when they expire. The client: 127 - 128 - - Refreshes 60 seconds before expiry 129 - - Uses a multi-tab lock to prevent duplicate refresh requests 130 - - Falls back gracefully if refresh fails (clears session) 131 - 132 - No manual token management is needed. 133 - 134 - ## Logout 135 - 136 - ```typescript 137 - export async function logout(): Promise<void> { 138 - await getOAuthClient().logout() 139 - } 140 - ``` 141 - 142 - This clears all stored tokens and DPoP keys from browser storage. 143 - 144 - ## How it works 145 - 146 - The OAuth flow uses these endpoints on your hatk server: 147 - 148 - | Endpoint | Purpose | 149 - | --------------------------------- | ------------------------------------------------- | 150 - | `POST /oauth/par` | Pushed Authorization Request — initiates the flow | 151 - | `GET /oauth/authorize` | Redirects to the user's PDS | 152 - | `POST /oauth/token` | Exchanges authorization code for tokens | 153 - | `GET /oauth/jwks` | Public keys for token verification | 154 - | `GET /oauth/client-metadata.json` | Client metadata discovery | 155 - 156 - All token requests include DPoP proofs (ECDSA P-256 key pairs stored in IndexedDB), which bind access tokens to the specific browser that requested them. 157 - 158 - ## Server-side: `viewer` 159 - 160 - On the backend, authenticated requests populate `ctx.viewer` in your XRPC handlers and feed generators: 161 - 162 - ```typescript 163 - export default defineQuery('my.endpoint', async (ctx) => { 164 - if (!ctx.viewer) throw new Error('Authentication required') 165 - const { did } = ctx.viewer 166 - // ... 167 - }) 168 - ``` 169 - 170 - ## Config 171 - 172 - Enable OAuth in `config.yaml`: 173 - 174 - ```yaml 175 - oauth: 176 - issuer: https://my-hatk.example.com 177 - clients: 178 - - client_id: https://my-hatk.example.com/oauth-client-metadata.json 179 - client_name: My App 180 - scope: atproto transition:generic 181 - ``` 182 - 183 - See [Configuration](/getting-started/configuration) for all OAuth options.
+63 -187
docs/site/guides/opengraph.md
··· 3 3 description: Generate dynamic OpenGraph images for link previews. 4 4 --- 5 5 6 - Hatk can generate dynamic OpenGraph images so your pages get rich previews when shared on social media, chat apps, and other platforms. 6 + Hatk generates dynamic OpenGraph images so your pages get rich previews when shared. You define a `generate` function that returns a virtual DOM tree, and Hatk renders it to a 1200x630 PNG using [Satori](https://github.com/vercel/satori). 7 7 8 - ## How it works 8 + ## Defining an OG route 9 9 10 - 1. You create handler files in the `og/` directory 11 - 2. Each handler defines a URL pattern and a `generate` function that returns a virtual DOM tree 12 - 3. The framework renders it to a 1200x630 PNG using [satori](https://github.com/vercel/satori) and [resvg](https://github.com/nicbarker/resvg-js) 13 - 4. Requests to `/og/*` serve the generated image 14 - 5. For matching page routes, the framework auto-injects `og:image` and `twitter:card` meta tags into your HTML 10 + Create a file in `server/og/` that exports `defineOG()` with a path pattern and a generate function: 15 11 16 - ## Creating an OpenGraph route 12 + ```typescript 13 + // server/og/artist.ts 14 + import { defineOG } from '$hatk' 17 15 18 - Generate a new handler: 16 + export default defineOG('/og/artist/:artist', async (ctx) => { 17 + const { db, params, fetchImage } = ctx 19 18 20 - ```bash 21 - hatk generate og artist 22 - ``` 19 + const rows = await db.query( 20 + `SELECT CAST(COUNT(*) AS INTEGER) AS play_count 21 + FROM "fm.teal.alpha.feed.play__artists" 22 + WHERE artist_name = ?`, 23 + [params.artist], 24 + ) 25 + const stats = rows[0] || { play_count: 0 } 23 26 24 - This creates `og/artist.ts`: 25 - 26 - ```typescript 27 - import type { OpengraphContext, OpengraphResult } from 'hatk/opengraph' 28 - 29 - export default { 30 - path: '/og/artist/:id', 31 - async generate(ctx: OpengraphContext): Promise<OpengraphResult> { 32 - const { db, params } = ctx 33 - return { 34 - element: { 35 - type: 'div', 36 - props: { 37 - style: { 38 - display: 'flex', 39 - width: '100%', 40 - height: '100%', 41 - background: '#080b12', 42 - color: 'white', 43 - alignItems: 'center', 44 - justifyContent: 'center', 45 - }, 46 - children: params.id, 27 + return { 28 + element: { 29 + type: 'div', 30 + props: { 31 + style: { 32 + display: 'flex', 33 + width: '100%', 34 + height: '100%', 35 + background: '#070a11', 36 + color: 'white', 37 + alignItems: 'center', 38 + justifyContent: 'center', 39 + flexDirection: 'column', 47 40 }, 41 + children: [ 42 + { type: 'div', props: { children: params.artist, style: { fontSize: 58, fontWeight: 700 } } }, 43 + { type: 'div', props: { children: `${stats.play_count} plays`, style: { fontSize: 28, color: '#94a3b8', marginTop: '16px' } } }, 44 + ], 48 45 }, 49 - } 50 - }, 51 - } 46 + }, 47 + meta: { title: params.artist }, 48 + } 49 + }) 52 50 ``` 53 51 54 - ## Path convention 52 + ## How it works 55 53 56 - The `path` field uses Express-style route parameters. The `/og` prefix is significant — the framework: 54 + The `path` field uses Express-style route parameters. The `/og` prefix is significant: 57 55 58 - - Serves `GET /og/artist/radiohead` as a PNG image 59 - - Auto-injects meta tags on `GET /artist/radiohead` (the `/og` prefix stripped) pointing to the OG image URL 56 + - `GET /og/artist/radiohead` serves the generated PNG 57 + - `GET /artist/radiohead` (the page route) automatically gets `og:image` meta tags injected pointing to the OG image URL 60 58 61 - This means your page routes and OG routes stay in sync automatically. If you have `og/artist.ts` with `path: '/og/artist/:name'`, then any visitor to `/artist/radiohead` gets meta tags injected: 59 + This keeps page routes and OG routes in sync. You don't need to add meta tags manually. 62 60 63 - ```html 64 - <meta property="og:image" content="https://yourapp.com/og/artist/radiohead" /> 65 - <meta property="og:image:width" content="1200" /> 66 - <meta property="og:image:height" content="630" /> 67 - <meta name="twitter:card" content="summary_large_image" /> 68 - ``` 69 - 70 - ## The generate function 71 - 72 - ### Context 61 + ## Generate context 73 62 74 63 The `generate` function receives an `OpengraphContext` with: 75 64 76 - | Field | Type | Description | 77 - | ------------ | -------- | ------------------------------------------------------- | 78 - | `db.query` | function | Run SQL queries against DuckDB | 79 - | `params` | object | URL path parameters (e.g. `{ artist: 'Radiohead' }`) | 80 - | `fetchImage` | function | Fetch a remote image and return it as a base64 data URL | 81 - | `lookup` | function | Look up records by field value | 82 - | `count` | function | Count records by field value | 83 - | `labels` | function | Query labels for record URIs | 84 - | `blobUrl` | function | Resolve a blob reference to a CDN URL | 65 + | Field | Description | 66 + | --- | --- | 67 + | `db.query(sql, params?)` | Run SQL queries against SQLite | 68 + | `params` | URL path parameters (e.g. `{ artist: 'Radiohead' }`) | 69 + | `fetchImage(url)` | Fetch a remote image and return it as a base64 data URL for use in `img` elements | 70 + | `lookup(collection, field, values)` | Look up records by field value | 71 + | `count(collection, field, values)` | Count records by field value | 72 + | `labels(uris)` | Query labels for record URIs | 73 + | `blobUrl(did, cid)` | Resolve a blob reference to a URL | 85 74 86 - ### Return value 75 + ## Return value 87 76 88 77 Return an `OpengraphResult`: 89 78 90 - | Field | Required | Description | 91 - | --------- | -------- | ---------------------------------------------------------------------------------- | 92 - | `element` | Yes | A satori virtual DOM tree (see below) | 93 - | `options` | No | Override `width` (default 1200), `height` (default 630), or provide custom `fonts` | 94 - | `meta` | No | `title` and `description` for the injected meta tags | 79 + | Field | Required | Description | 80 + | --- | --- | --- | 81 + | `element` | Yes | A Satori virtual DOM tree | 82 + | `options` | No | Override `width` (default 1200), `height` (default 630), or provide custom `fonts` | 83 + | `meta` | No | `title` and `description` for the injected meta tags | 95 84 96 - ### Virtual DOM 85 + ## Virtual DOM 97 86 98 - Satori uses a React-like virtual DOM. Elements are plain objects with `type`, and `props` containing `style` and `children`: 87 + Satori uses a React-like virtual DOM. Elements are plain objects with `type` and `props` containing `style` and `children`: 99 88 100 89 ```typescript 101 90 { ··· 110 99 } 111 100 ``` 112 101 113 - Satori supports a subset of CSS flexbox. All layouts must use `display: 'flex'`. See the [satori docs](https://github.com/vercel/satori#css) for supported properties. 114 - 115 - ## Example: artist page with stats 116 - 117 - Here's a real example from teal.fm that queries play counts and fetches an artist image: 118 - 119 - ```typescript 120 - import type { OpengraphContext, OpengraphResult } from 'hatk/opengraph' 121 - 122 - export default { 123 - path: '/og/artist/:artist', 124 - async generate(ctx: OpengraphContext): Promise<OpengraphResult> { 125 - const { db, params, fetchImage } = ctx 126 - 127 - // Query stats from DuckDB 128 - const rows = await db.query( 129 - `SELECT COUNT(*)::INTEGER AS play_count, 130 - COUNT(DISTINCT p.did)::INTEGER AS listener_count 131 - FROM "fm.teal.alpha.feed.play" p, 132 - unnest(from_json(p.artists::JSON, '["json"]')) AS a(artist_json) 133 - WHERE json_extract_string(a.artist_json, '$.artistName') = $1`, 134 - [params.artist], 135 - ) 136 - const stats = rows[0] || { play_count: 0, listener_count: 0 } 137 - 138 - // Fetch artist thumbnail (returns base64 data URL or null) 139 - let artUrl = null 140 - try { 141 - const res = await fetch( 142 - `https://www.theaudiodb.com/api/v1/json/2/search.php?s=${encodeURIComponent(params.artist)}`, 143 - ) 144 - const data = await res.json() 145 - const thumbUrl = data?.artists?.[0]?.strArtistThumb 146 - if (thumbUrl) { 147 - artUrl = await fetchImage(`${thumbUrl}/small`) 148 - } 149 - } catch {} 150 - 151 - return { 152 - element: { 153 - type: 'div', 154 - props: { 155 - style: { 156 - display: 'flex', 157 - width: '100%', 158 - height: '100%', 159 - background: '#070a11', 160 - padding: '48px 64px', 161 - alignItems: 'center', 162 - gap: '56px', 163 - }, 164 - children: [ 165 - // Artist image 166 - ...(artUrl 167 - ? [ 168 - { 169 - type: 'img', 170 - props: { 171 - src: artUrl, 172 - width: 300, 173 - height: 300, 174 - style: { borderRadius: '20px', objectFit: 'cover' }, 175 - }, 176 - }, 177 - ] 178 - : []), 179 - // Text content 180 - { 181 - type: 'div', 182 - props: { 183 - style: { display: 'flex', flexDirection: 'column' }, 184 - children: [ 185 - { 186 - type: 'div', 187 - props: { 188 - children: params.artist, 189 - style: { fontSize: 58, fontWeight: 700, color: '#f1f5f9' }, 190 - }, 191 - }, 192 - { 193 - type: 'div', 194 - props: { 195 - children: `${stats.play_count.toLocaleString()} plays · ${stats.listener_count.toLocaleString()} listeners`, 196 - style: { fontSize: 28, color: '#94a3b8', marginTop: '16px' }, 197 - }, 198 - }, 199 - ], 200 - }, 201 - }, 202 - ], 203 - }, 204 - }, 205 - meta: { title: params.artist }, 206 - } 207 - }, 208 - } 209 - ``` 102 + All layouts must use `display: 'flex'`. See the [Satori docs](https://github.com/vercel/satori#css) for supported CSS properties. 210 103 211 104 ## Using `fetchImage` 212 105 213 - Remote images must be converted to base64 data URLs before satori can render them. Use `ctx.fetchImage()`: 106 + Remote images must be converted to base64 data URLs before Satori can render them: 214 107 215 108 ```typescript 216 109 const artUrl = await ctx.fetchImage('https://example.com/image.jpg') 217 - // Returns: "data:image/jpeg;base64,/9j/4AAQ..." or null on failure 110 + // Returns "data:image/jpeg;base64,..." or null on failure 218 111 ``` 219 112 220 113 Then pass it as the `src` on an `img` element. 221 114 222 115 ## Caching 223 116 224 - Generated images are cached in memory for 5 minutes (up to 200 entries). Subsequent requests for the same path return the cached PNG without re-rendering. 225 - 226 - ## Fonts 227 - 228 - A default Inter font is bundled. To use custom fonts, return them in the `options.fonts` array: 229 - 230 - ```typescript 231 - return { 232 - element: { ... }, 233 - options: { 234 - fonts: [ 235 - { name: 'MyFont', data: fontBuffer, weight: 400, style: 'normal' }, 236 - ], 237 - }, 238 - } 239 - ``` 240 - 241 - Custom fonts are merged with the default Inter font, so you can use either. 117 + Generated images are cached in memory for 5 minutes (up to 200 entries). A default Inter font is bundled; custom fonts can be provided via `options.fonts`.
+86 -34
docs/site/guides/seeds.md
··· 3 3 description: Create test data for local development. 4 4 --- 5 5 6 - Seeds populate your local PDS with test data during development. Place your seed script at `seeds/seed.ts`. 6 + Seeds populate your local PDS with test accounts and records during development. They live at `seeds/seed.ts` and use the generated `seed()` helper. 7 7 8 - ## How it works 8 + ## Minimal example 9 9 10 - Seeds run automatically during `hatk dev` after the PDS starts, or manually with `hatk seed`. They use the generated `seed()` helper to create accounts and records against the local PDS. 10 + ```typescript 11 + import { seed } from '$hatk' 12 + 13 + const { createAccount, createRecord } = seed() 11 14 12 - ## Example 15 + const alice = await createAccount('alice.test') 16 + 17 + await createRecord(alice, 'app.bsky.actor.profile', { 18 + displayName: 'Alice', 19 + description: 'Test user', 20 + }, { rkey: 'self' }) 21 + 22 + await createRecord(alice, 'xyz.statusphere.status', { 23 + status: '🚀', 24 + createdAt: new Date().toISOString(), 25 + }) 26 + ``` 27 + 28 + Seeds run automatically during `hatk dev` after the PDS starts. You can also run them manually: 29 + 30 + ```bash 31 + hatk seed # run seeds/seed.ts 32 + hatk reset # wipe all data and re-seed from scratch 33 + ``` 34 + 35 + ## Complete seed file 36 + 37 + A more realistic seed file creates multiple accounts, uploads images, creates follows, and staggers timestamps so time-based feeds have data to work with: 13 38 14 39 ```typescript 15 - import { seed } from '../hatk.generated.ts' 40 + import { seed } from '$hatk' 16 41 17 42 const { createAccount, createRecord, uploadBlob } = seed() 18 43 19 - // Create test accounts 20 44 const alice = await createAccount('alice.test') 21 45 const bob = await createAccount('bob.test') 46 + const carol = await createAccount('carol.test') 22 47 23 - // Upload an avatar 24 - const avatar = await uploadBlob(alice, './seeds/images/alice.png') 48 + // Stagger records over the last hour 49 + const now = Date.now() 50 + const ago = (minutes: number) => new Date(now - minutes * 60_000).toISOString() 25 51 26 - // Create a profile 52 + // Profile with an avatar (upload returns a blob ref) 53 + const aliceAvatar = await uploadBlob(alice, './seeds/images/alice.png') 27 54 await createRecord( 28 55 alice, 29 56 'app.bsky.actor.profile', 30 57 { 31 58 displayName: 'Alice', 32 - description: 'Test user', 33 - avatar, 59 + description: 'Indie and alt-pop listener', 60 + avatar: aliceAvatar, 34 61 }, 35 62 { rkey: 'self' }, 36 63 ) 37 64 38 - // Create records 39 - await createRecord(alice, 'fm.teal.alpha.feed.play', { 40 - trackName: 'Blinding Lights', 41 - releaseName: 'After Hours', 42 - artists: [{ artistName: 'The Weeknd' }], 43 - playedTime: new Date().toISOString(), 44 - }) 65 + // Follows — Alice follows Bob and Carol 66 + await createRecord( 67 + alice, 68 + 'app.bsky.graph.follow', 69 + { subject: bob.did, createdAt: new Date().toISOString() }, 70 + { rkey: 'bob' }, 71 + ) 72 + await createRecord( 73 + alice, 74 + 'app.bsky.graph.follow', 75 + { subject: carol.did, createdAt: new Date().toISOString() }, 76 + { rkey: 'carol' }, 77 + ) 45 78 46 - // Bob follows Alice 47 - await createRecord(bob, 'app.bsky.graph.follow', { 48 - subject: alice.did, 49 - createdAt: new Date().toISOString(), 50 - }) 79 + // App records with staggered timestamps 80 + await createRecord( 81 + alice, 82 + 'fm.teal.alpha.feed.play', 83 + { 84 + trackName: 'Blinding Lights', 85 + artists: [{ artistName: 'The Weeknd' }], 86 + releaseName: 'After Hours', 87 + playedTime: ago(50), 88 + }, 89 + { rkey: 'blinding-lights' }, 90 + ) 91 + 92 + await createRecord( 93 + bob, 94 + 'fm.teal.alpha.feed.play', 95 + { 96 + trackName: 'HUMBLE.', 97 + artists: [{ artistName: 'Kendrick Lamar' }], 98 + releaseName: 'DAMN.', 99 + playedTime: ago(30), 100 + }, 101 + { rkey: 'humble' }, 102 + ) 103 + 104 + console.log('\n[seed] Done!') 51 105 ``` 52 106 53 107 ## `seed()` helpers 54 108 55 - | Function | Description | 56 - | -------------------------------------------------- | -------------------------------------------------------------------- | 57 - | `createAccount(handle)` | Create a test account on the local PDS. Returns `{ did, handle }` | 58 - | `createRecord(account, collection, record, opts?)` | Create a record. Pass `{ rkey }` in opts for a specific record key | 59 - | `uploadBlob(account, filePath)` | Upload a file as a blob. Returns a blob reference for use in records | 109 + | Function | Description | 110 + | --- | --- | 111 + | `createAccount(handle)` | Create a test account on the local PDS. Returns `{ did, handle }` | 112 + | `createRecord(account, collection, record, opts?)` | Create a record. Pass `{ rkey }` in opts for a specific record key | 113 + | `uploadBlob(account, filePath)` | Upload a file and return a blob reference for use in records | 114 + 115 + Records are validated against your project's lexicons before being written, so you get errors at seed time if the data doesn't match your schema. 60 116 61 117 ## Tips 62 118 63 119 - Use `{ rkey: 'self' }` for singleton records like profiles 64 120 - Place test images in `seeds/images/` 65 - - Stagger timestamps to test time-based feeds: 66 - ```typescript 67 - const now = Date.now() 68 - const ago = (minutes: number) => new Date(now - minutes * 60_000).toISOString() 69 - ``` 70 - - Run `hatk reset` to wipe and re-seed from scratch 121 + - Use the `ago` helper pattern to spread records across time for testing feeds and cursors 122 + - `createAccount` reuses an existing account if the handle already exists, so re-running seeds is safe
+116 -57
docs/site/guides/xrpc-handlers.md
··· 1 1 --- 2 2 title: XRPC Handlers 3 - description: Define custom query and procedure endpoints. 3 + description: Define typed API endpoints — queries for GET, procedures for POST. 4 4 --- 5 5 6 - Custom XRPC handlers extend your hatk server's API beyond the built-in endpoints. Place them in the `xrpc/` directory, organized by namespace. 6 + # XRPC Handlers 7 7 8 - ```bash 9 - hatk generate xrpc dev.hatk.unspecced.getPlay 10 - ``` 8 + XRPC handlers are typed API endpoints that extend your hatk server's API. They come in two kinds: **queries** (read-only GET requests) and **procedures** (POST requests that can modify data). Each handler maps to a lexicon that defines its parameter types, input/output schemas, and error cases. 11 9 12 - This creates `xrpc/dev/hatk/unspecced/getPlay.ts`. 13 - 14 - ## `defineQuery` 10 + ## Defining a query 15 11 16 - For read-only GET endpoints: 12 + Use `defineQuery()` for read-only GET endpoints. The handler receives a typed context object and returns a response via `ctx.ok()`: 17 13 18 14 ```typescript 19 - import { defineQuery } from '../../../../hatk.generated.ts' 15 + // server/xrpc/getPlay.ts 16 + import { defineQuery, NotFoundError, views, type Play, type Profile } from "$hatk"; 20 17 21 - export default defineQuery('dev.hatk.unspecced.getPlay', async (ctx) => { 22 - const { ok, params, resolve, lookup, blobUrl } = ctx 23 - const { uri } = params 18 + export default defineQuery("xyz.appview.unspecced.getPlay", async (ctx) => { 19 + const { ok, params, resolve, lookup, blobUrl } = ctx; 20 + const { uri } = params; 24 21 25 - const records = await resolve([uri]) 26 - if (records.length === 0) throw new NotFoundError('Play not found') 22 + const records = await resolve<Play>([uri]); 23 + if (records.length === 0) throw new NotFoundError("Play not found"); 27 24 28 - const record = records[0] 29 - const profiles = await lookup('app.bsky.actor.profile', 'did', [record.did]) 30 - const profile = profiles.get(record.did) 25 + const record = records[0]; 26 + const profiles = await lookup<Profile>("app.bsky.actor.profile", "did", [record.did]); 27 + const profile = profiles.get(record.did); 31 28 32 29 return ok({ 33 - play: { 34 - uri: record.uri, 35 - did: record.did, 36 - handle: record.handle, 37 - ...record.value, 30 + play: views.playView({ 31 + record: { 32 + uri: record.uri, 33 + did: record.did, 34 + handle: record.handle, 35 + ...record.value, 36 + }, 38 37 author: profile 39 38 ? { 40 39 did: profile.did, 41 40 handle: profile.handle, 42 41 displayName: profile.value.displayName, 43 - avatar: blobUrl(profile.did, profile.value.avatar, 'avatar'), 42 + avatar: blobUrl(profile.did, profile.value.avatar, "avatar"), 44 43 } 45 44 : undefined, 46 - }, 47 - }) 48 - }) 45 + }), 46 + }); 47 + }); 49 48 ``` 50 49 51 - ## `defineProcedure` 50 + The `params` object is typed from your lexicon's parameter definitions. In this case, `params.uri` is a string because the lexicon declares it. The `ok()` function enforces the output schema at the type level -- if your return value doesn't match, TypeScript will error. 52 51 53 - For write POST endpoints. The request body is available via `ctx.input`, typed from your lexicon's input schema: 52 + ## Defining a procedure 53 + 54 + Use `defineProcedure()` for POST endpoints that modify data. The request body is available via `ctx.input`, typed from your lexicon's input schema: 54 55 55 56 ```typescript 56 - import { defineProcedure } from '../../../../hatk.generated.ts' 57 + // server/xrpc/doSomething.ts 58 + import { defineProcedure } from "$hatk"; 57 59 58 - export default defineProcedure('dev.hatk.unspecced.doSomething', async (ctx) => { 59 - const { ok, db, viewer, input } = ctx 60 + export default defineProcedure("dev.hatk.unspecced.doSomething", async (ctx) => { 61 + const { ok, db, viewer, input } = ctx; 60 62 61 - if (!viewer) throw new Error('Authentication required') 63 + if (!viewer) throw new Error("Authentication required"); 62 64 63 - // input is typed from the lexicon's input schema 64 - const { name, value } = input 65 + const { name, value } = input; 65 66 66 67 await db.run( 67 68 `INSERT INTO my_table (did, name, value, created_at) VALUES ($1, $2, $3, $4)`, ··· 69 70 name, 70 71 value, 71 72 new Date().toISOString(), 72 - ) 73 + ); 73 74 74 - return ok({}) 75 - }) 75 + return ok({}); 76 + }); 76 77 ``` 77 78 78 - ## Context 79 + ## Context reference 79 80 80 - Both `defineQuery` and `defineProcedure` receive the same context: 81 + Both `defineQuery` and `defineProcedure` handlers receive the same context object: 81 82 82 83 | Field | Type | Description | 83 84 | --------------------- | ------------------------- | --------------------------------------------------------------------- | 84 - | `db.query` | function | Run SQL queries against DuckDB | 85 - | `db.run` | function | Execute SQL statements | 85 + | `ok` | function | Wraps your return value with type checking | 86 86 | `params` | object | Typed parameters from the lexicon schema | 87 87 | `input` | object | Request body (procedures only), typed from the lexicon's input schema | 88 + | `db.query` | function | Run SQL queries against your SQLite database | 89 + | `db.run` | function | Execute SQL statements (INSERT, UPDATE, DELETE) | 90 + | `viewer` | `{ did: string }` \| null | The authenticated user, or null | 88 91 | `limit` | number | Requested page size | 89 92 | `cursor` | string \| undefined | Pagination cursor | 90 - | `viewer` | `{ did: string }` \| null | The authenticated user, or null | 91 - | `ok` | function | Wraps your return value with type checking | 92 - | `packCursor` | function | Encode a `(primary, cid)` pair into a cursor string | 93 - | `unpackCursor` | function | Decode a cursor back into `{ primary, cid }` | 94 - | `search` | function | Full-text search a collection | 95 93 | `resolve` | function | Resolve AT URIs into full records | 96 94 | `lookup` | function | Look up records by a field value | 97 95 | `count` | function | Count records by field value | 98 96 | `exists` | function | Check if a record exists matching field filters | 97 + | `search` | function | Full-text search a collection | 99 98 | `labels` | function | Query labels for a list of URIs | 100 99 | `blobUrl` | function | Resolve a blob reference to a CDN URL | 100 + | `packCursor` | function | Encode a `(primary, cid)` pair into a cursor string | 101 + | `unpackCursor` | function | Decode a cursor back into `{ primary, cid }` | 101 102 | `isTakendown` | function | Check if a DID has been taken down | 102 103 | `filterTakendownDids` | function | Filter a list of DIDs, returning those taken down | 103 104 104 - ## Errors 105 + ### `ctx.ok()` 105 106 106 - Import error classes from your generated types: 107 + Every handler must return `ctx.ok(data)`. This wraps your response with type checking against the lexicon's output schema. If the shape doesn't match, TypeScript catches it at compile time. 108 + 109 + ### `ctx.db.query()` and `ctx.db.run()` 110 + 111 + Run SQL against your SQLite database. Use `db.query()` for SELECT statements that return rows, and `db.run()` for INSERT/UPDATE/DELETE: 107 112 108 113 ```typescript 109 - import { NotFoundError, InvalidRequestError } from '../../../../hatk.generated.ts' 114 + // Query — returns rows 115 + const rows = await db.query( 116 + `SELECT CAST(COUNT(*) AS INTEGER) AS play_count 117 + FROM "fm.teal.alpha.feed.play" 118 + WHERE did = $1`, 119 + [params.actor], 120 + ); 121 + 122 + // Run — executes a statement 123 + await db.run( 124 + `INSERT INTO my_table (did, value) VALUES ($1, $2)`, 125 + viewer.did, 126 + input.value, 127 + ); 128 + ``` 129 + 130 + ### `ctx.resolve()` and `ctx.lookup()` 110 131 111 - // 404 112 - throw new NotFoundError('Play not found') 132 + These helpers fetch records without writing raw SQL: 113 133 114 - // 400 115 - throw new InvalidRequestError('Missing required field') 134 + ```typescript 135 + // Resolve AT URIs into full records 136 + const records = await resolve<Play>([uri]); 137 + 138 + // Look up records by a field value — returns a Map keyed by the field 139 + const profiles = await lookup<Profile>("app.bsky.actor.profile", "did", [did1, did2]); 140 + const profile = profiles.get(did1); 116 141 ``` 117 142 143 + ### `ctx.viewer` 144 + 145 + `viewer` is `{ did: string }` when the request comes from an authenticated user, or `null` for unauthenticated requests. Check it to protect endpoints that require authentication: 146 + 147 + ```typescript 148 + if (!viewer) throw new Error("Authentication required"); 149 + ``` 150 + 151 + ## Error handling 152 + 153 + Import error classes from your generated types to throw standard XRPC errors: 154 + 155 + ```typescript 156 + import { NotFoundError, InvalidRequestError } from "$hatk"; 157 + 158 + // 404 — record not found 159 + throw new NotFoundError("Play not found"); 160 + 161 + // 400 — bad request 162 + throw new InvalidRequestError("Missing required field"); 163 + ``` 164 + 165 + These map to standard XRPC error responses that clients can handle predictably. 166 + 118 167 ## Lexicon pairing 119 168 120 - Each handler must have a matching lexicon in `lexicons/`. The handler file path mirrors the NSID: 169 + Each handler must have a matching lexicon definition. The file path mirrors the NSID: 121 170 122 171 ``` 123 - lexicons/dev/hatk/unspecced/getPlay.json → xrpc/dev/hatk/unspecced/getPlay.ts 172 + lexicons/dev/hatk/unspecced/getPlay.json → server/xrpc/getPlay.ts 173 + ``` 174 + 175 + The lexicon defines parameter types, input/output schemas, and whether the endpoint is a query or procedure. See the [AT Protocol lexicon docs](https://atproto.com/specs/lexicon) for schema details. 176 + 177 + ## Generating a handler 178 + 179 + Use the CLI to scaffold a new handler: 180 + 181 + ```bash 182 + hatk generate xrpc dev.hatk.unspecced.getPlay 124 183 ``` 125 184 126 - The lexicon defines the parameter types, input/output schemas, and whether the endpoint is a query or procedure. The `ok()` function enforces the output schema at the type level — if your return value doesn't match, TypeScript will error. 185 + This creates the handler file with the right imports and structure.
+35 -2
docs/site/index.md
··· 2 2 layout: home 3 3 4 4 hero: 5 - name: Hatk 6 - tagline: Scaffold, index, and serve AT Protocol apps with typed XRPC endpoints. 5 + name: hatk 6 + tagline: Build AT Protocol apps with typed XRPC endpoints. 7 7 actions: 8 8 - theme: brand 9 9 text: Get Started ··· 11 11 - theme: alt 12 12 text: CLI Reference 13 13 link: /cli/ 14 + 15 + features: 16 + - title: Typed end-to-end 17 + details: Lexicons generate TypeScript types for records, queries, and feeds. Your editor catches mistakes before your users do. 18 + - title: SQLite by default 19 + details: No external database to configure. Data lives in a single file that just works — locally and in production. 20 + - title: OAuth built-in 21 + details: AT Protocol auth with session cookies. Login, logout, and viewer resolution with zero setup. 22 + - title: SvelteKit-first 23 + details: Full-stack with SSR, remote commands, and typed XRPC calls from a generated client. 14 24 --- 25 + 26 + ## Project Structure 27 + 28 + A hatk app looks like this: 29 + 30 + ``` 31 + my-app/ 32 + ├── app/ # SvelteKit frontend 33 + │ ├── routes/ 34 + │ │ ├── +layout.server.ts # parseViewer(cookies) 35 + │ │ └── +page.svelte # Your UI 36 + │ └── lib/ 37 + ├── server/ # Backend handlers 38 + │ ├── feeds/ # Feed generators 39 + │ │ └── recent.ts # defineFeed({ ... }) 40 + │ └── xrpc/ # Custom XRPC endpoints 41 + │ └── getProfile.ts # defineQuery('...', ...) 42 + ├── seeds/ 43 + │ └── seed.ts # Test fixture data 44 + ├── lexicons/ # AT Protocol schemas (like Prisma models) 45 + ├── hatk.config.ts # Server configuration 46 + └── hatk.generated.ts # Auto-generated types from lexicons 47 + ```
+517
docs/superpowers/plans/2026-03-18-docs-overhaul.md
··· 1 + # Docs Overhaul Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Rewrite all hatk documentation to reflect the current API (generated client, SQLite-only, SvelteKit remote commands, `parseViewer`/`login`/`logout` from `$hatk/client`). 6 + 7 + **Architecture:** VitePress site at `docs/site/`. Rewrite every page in-place, add new `frontend/` section, update nav config. Source examples from `~/code/hatk-template-statusphere` and `~/code/hatk-template-teal`. 8 + 9 + **Tech Stack:** VitePress, Markdown, TypeScript code blocks 10 + 11 + **Design doc:** `docs/plans/2026-03-18-docs-overhaul-design.md` 12 + 13 + --- 14 + 15 + ## Batch 1: Foundation (config + landing page + nav) 16 + 17 + ### Task 1: Update VitePress config and nav 18 + 19 + **Files:** 20 + - Modify: `docs/site/.vitepress/config.ts` 21 + 22 + **Step 1: Rewrite the config** 23 + 24 + Replace the full config with updated nav and sidebar. Add Frontend section, remove api-client, rename OAuth → Auth & OAuth, remove Deployment from guides (move to CLI build page). 25 + 26 + ```ts 27 + import { defineConfig } from 'vitepress' 28 + 29 + export default defineConfig({ 30 + title: 'hatk', 31 + description: 'Build AT Protocol applications with typed XRPC endpoints.', 32 + 33 + themeConfig: { 34 + nav: [ 35 + { text: 'Guide', link: '/getting-started/quickstart' }, 36 + { text: 'Frontend', link: '/frontend/setup' }, 37 + { text: 'CLI', link: '/cli/' }, 38 + { text: 'API', link: '/api/' }, 39 + ], 40 + 41 + sidebar: [ 42 + { 43 + text: 'Getting Started', 44 + items: [ 45 + { text: 'Quickstart', link: '/getting-started/quickstart' }, 46 + { text: 'Project Structure', link: '/getting-started/project-structure' }, 47 + { text: 'Configuration', link: '/getting-started/configuration' }, 48 + ], 49 + }, 50 + { 51 + text: 'Guides', 52 + items: [ 53 + { text: 'Feeds', link: '/guides/feeds' }, 54 + { text: 'XRPC Handlers', link: '/guides/xrpc-handlers' }, 55 + { text: 'Auth & OAuth', link: '/guides/auth' }, 56 + { text: 'Seeds', link: '/guides/seeds' }, 57 + { text: 'Labels', link: '/guides/labels' }, 58 + { text: 'OpenGraph', link: '/guides/opengraph' }, 59 + { text: 'Hooks', link: '/guides/hooks' }, 60 + ], 61 + }, 62 + { 63 + text: 'Frontend', 64 + items: [ 65 + { text: 'SvelteKit Setup', link: '/frontend/setup' }, 66 + { text: 'Data Loading', link: '/frontend/data-loading' }, 67 + { text: 'Mutations', link: '/frontend/mutations' }, 68 + ], 69 + }, 70 + { 71 + text: 'CLI Reference', 72 + items: [ 73 + { text: 'Overview', link: '/cli/' }, 74 + { text: 'Scaffolding', link: '/cli/scaffold' }, 75 + { text: 'Development', link: '/cli/development' }, 76 + { text: 'Testing', link: '/cli/testing' }, 77 + { text: 'Build & Deploy', link: '/cli/build' }, 78 + ], 79 + }, 80 + { 81 + text: 'API Reference', 82 + items: [ 83 + { text: 'Overview', link: '/api/' }, 84 + { text: 'Records', link: '/api/records' }, 85 + { text: 'Feeds', link: '/api/feeds' }, 86 + { text: 'Search', link: '/api/search' }, 87 + { text: 'Blobs', link: '/api/blobs' }, 88 + { text: 'Preferences', link: '/api/preferences' }, 89 + { text: 'Labels', link: '/api/labels' }, 90 + ], 91 + }, 92 + ], 93 + 94 + socialLinks: [{ icon: 'github', link: 'https://github.com/bigmoves/hatk' }], 95 + }, 96 + }) 97 + ``` 98 + 99 + **Step 2: Verify** 100 + 101 + Run: `cd docs/site && npx vitepress build 2>&1 | tail -5` 102 + Expected: Build succeeds (some dead links expected until pages are written) 103 + 104 + **Step 3: Commit** 105 + 106 + ```bash 107 + git add docs/site/.vitepress/config.ts 108 + git commit -m "docs: update nav structure for docs overhaul" 109 + ``` 110 + 111 + ### Task 2: Rewrite landing page 112 + 113 + **Files:** 114 + - Modify: `docs/site/index.md` 115 + 116 + **Step 1: Rewrite with project tree and feature cards** 117 + 118 + ```markdown 119 + --- 120 + layout: home 121 + 122 + hero: 123 + name: hatk 124 + tagline: Build AT Protocol apps with typed XRPC endpoints. 125 + actions: 126 + - theme: brand 127 + text: Get Started 128 + link: /getting-started/quickstart 129 + - theme: alt 130 + text: CLI Reference 131 + link: /cli/ 132 + 133 + features: 134 + - title: Typed end-to-end 135 + details: Lexicons generate TypeScript types for records, queries, and feeds. Your editor catches mistakes before your users do. 136 + - title: SQLite by default 137 + details: No external database to configure. Data lives in a single file that just works — locally and in production. 138 + - title: OAuth built-in 139 + details: AT Protocol auth with session cookies. Login, logout, and viewer resolution with zero setup. 140 + - title: SvelteKit-first 141 + details: Full-stack with SSR, remote commands, and typed XRPC calls from a generated client. 142 + --- 143 + 144 + ## Project Structure 145 + 146 + A hatk app looks like this: 147 + 148 + ``` 149 + my-app/ 150 + ├── app/ # SvelteKit frontend 151 + │ ├── routes/ 152 + │ │ ├── +layout.server.ts # parseViewer(cookies) 153 + │ │ └── +page.svelte # Your UI 154 + │ └── lib/ 155 + ├── server/ # Backend handlers 156 + │ ├── feeds/ # Feed generators 157 + │ │ └── recent.ts # defineFeed({ ... }) 158 + │ └── xrpc/ # Custom XRPC endpoints 159 + │ └── getProfile.ts # defineQuery('...', ...) 160 + ├── seeds/ 161 + │ └── seed.ts # Test fixture data 162 + ├── lexicons/ # AT Protocol schemas (like Prisma models) 163 + ├── hatk.config.ts # Server configuration 164 + └── hatk.generated.ts # Auto-generated types from lexicons 165 + ``` 166 + ``` 167 + 168 + **Step 2: Commit** 169 + 170 + ```bash 171 + git add docs/site/index.md 172 + git commit -m "docs: rewrite landing page with project tree and features" 173 + ``` 174 + 175 + --- 176 + 177 + ## Batch 2: Getting Started (3 pages) 178 + 179 + ### Task 3: Rewrite quickstart 180 + 181 + **Files:** 182 + - Modify: `docs/site/getting-started/quickstart.md` 183 + 184 + Rewrite to reflect current CLI (`hatk new` with `--svelte` default, `npm run dev` instead of `npx hatk dev`, `hatk.config.ts` not `config.yaml`). Steps: 185 + 186 + 1. Prerequisites (Node 22+, Docker for local dev) 187 + 2. `npx hatk new my-app` — show the project it creates 188 + 3. `cd my-app && npm run dev` — starts PDS, seeds, dev server 189 + 4. Open `http://localhost:5173`, see the app 190 + 5. Next steps links 191 + 192 + Source accurate project structure from `~/code/hatk-template-statusphere`. Reference `hatk.config.ts` not `config.yaml`. No DuckDB mentions. 193 + 194 + **Step 1: Rewrite the page** 195 + **Step 2: Commit** 196 + 197 + ```bash 198 + git add docs/site/getting-started/quickstart.md 199 + git commit -m "docs: rewrite quickstart for current CLI and project structure" 200 + ``` 201 + 202 + ### Task 4: Rewrite project structure 203 + 204 + **Files:** 205 + - Modify: `docs/site/getting-started/project-structure.md` 206 + 207 + Expanded annotated tree matching actual template layout: 208 + - `app/` — SvelteKit frontend, routes, lib 209 + - `server/` — feeds, xrpc handlers, hooks, labels, og, setup 210 + - `seeds/` — test fixtures 211 + - `lexicons/` — AT Protocol schemas with brief explanation 212 + - `hatk.config.ts` — link to configuration page 213 + - `hatk.generated.ts` / `hatk.generated.client.ts` — what they contain 214 + - `vite.config.ts`, `svelte.config.js`, `tsconfig.json` — brief notes 215 + 216 + Each directory gets 2-3 sentences and a link to the relevant guide. 217 + 218 + **Step 1: Rewrite the page** 219 + **Step 2: Commit** 220 + 221 + ```bash 222 + git add docs/site/getting-started/project-structure.md 223 + git commit -m "docs: rewrite project structure with annotated tree" 224 + ``` 225 + 226 + ### Task 5: Rewrite configuration 227 + 228 + **Files:** 229 + - Modify: `docs/site/getting-started/configuration.md` 230 + 231 + Reference: read `~/code/hatk/packages/hatk/src/cli.ts` for the config type definition, and `~/code/hatk-template-statusphere/hatk.config.ts` for a real example. 232 + 233 + Structure: 234 + 1. Minimal config example at top 235 + 2. Full reference table grouped by: Server, Database, Backfill, OAuth 236 + 3. Each option: name, type, default, description 237 + 4. No DuckDB options. No `config.yaml` references. 238 + 239 + **Step 1: Rewrite the page** 240 + **Step 2: Commit** 241 + 242 + ```bash 243 + git add docs/site/getting-started/configuration.md 244 + git commit -m "docs: rewrite configuration for hatk.config.ts" 245 + ``` 246 + 247 + --- 248 + 249 + ## Batch 3: Guides — core (3 pages) 250 + 251 + ### Task 6: Rewrite feeds guide 252 + 253 + **Files:** 254 + - Modify: `docs/site/guides/feeds.md` 255 + 256 + Source examples from `~/code/hatk-template-statusphere/server/recent.ts` and `~/code/hatk-template-teal/server/feeds/`. Replace all DuckDB references with SQLite. Keep the `generate`/`hydrate` context tables but update them. 257 + 258 + Pattern: what feeds do → minimal example → `generate` context reference → `paginate` helper → `hydrate` context reference → full example with hydration. 259 + 260 + **Step 1: Rewrite the page** 261 + **Step 2: Commit** 262 + 263 + ```bash 264 + git add docs/site/guides/feeds.md 265 + git commit -m "docs: rewrite feeds guide with SQLite examples" 266 + ``` 267 + 268 + ### Task 7: Rewrite XRPC handlers guide 269 + 270 + **Files:** 271 + - Modify: `docs/site/guides/xrpc-handlers.md` 272 + 273 + Source examples from `~/code/hatk-template-teal/server/xrpc/`. Cover `defineQuery()` and `defineProcedure()` with typed `Ctx<K>`. Show `ctx.ok()`, `ctx.db.query()`, `ctx.lookup()`, `ctx.viewer`. 274 + 275 + **Step 1: Read current page and template examples** 276 + **Step 2: Rewrite the page** 277 + **Step 3: Commit** 278 + 279 + ```bash 280 + git add docs/site/guides/xrpc-handlers.md 281 + git commit -m "docs: rewrite XRPC handlers guide" 282 + ``` 283 + 284 + ### Task 8: Rewrite auth guide (rename oauth.md → auth.md) 285 + 286 + **Files:** 287 + - Create: `docs/site/guides/auth.md` 288 + - Delete: `docs/site/guides/oauth.md` 289 + - Delete: `docs/site/guides/api-client.md` 290 + - Delete: `docs/site/guides/frontend.md` 291 + 292 + Complete rewrite. No `OAuthClient` class. Structure: 293 + 1. Overview — hatk handles OAuth server-side with session cookies 294 + 2. Config — `hatk.config.ts` oauth section with scopes and clients 295 + 3. Frontend auth — `login(handle)` and `logout()` from `$hatk/client` 296 + 4. Server-side — `parseViewer(cookies)` in `+layout.server.ts`, `getViewer()` in handlers, `ctx.viewer` 297 + 5. Complete example showing config → layout → login form → protected route 298 + 299 + Source from `~/code/hatk-template-statusphere` for the simple case. 300 + 301 + Also delete `api-client.md` and `frontend.md` since their content moves to the new Frontend section. 302 + 303 + **Step 1: Write new auth.md** 304 + **Step 2: Delete old files** 305 + **Step 3: Commit** 306 + 307 + ```bash 308 + git add docs/site/guides/auth.md 309 + git rm docs/site/guides/oauth.md docs/site/guides/api-client.md docs/site/guides/frontend.md 310 + git commit -m "docs: rewrite auth guide, remove obsolete oauth/api-client/frontend pages" 311 + ``` 312 + 313 + --- 314 + 315 + ## Batch 4: Guides — remaining (4 pages) 316 + 317 + ### Task 9: Rewrite seeds guide 318 + 319 + **Files:** 320 + - Modify: `docs/site/guides/seeds.md` 321 + 322 + Source from `~/code/hatk-template-statusphere/seeds/seed.ts` and `~/code/hatk-template-teal/seeds/seed.ts`. Show `seed()` with `createAccount`, `createRecord`, `uploadBlob`. Mention `hatk seed` and `hatk reset`. 323 + 324 + **Step 1: Rewrite the page** 325 + **Step 2: Commit** 326 + 327 + ```bash 328 + git add docs/site/guides/seeds.md 329 + git commit -m "docs: rewrite seeds guide" 330 + ``` 331 + 332 + ### Task 10: Rewrite labels guide 333 + 334 + **Files:** 335 + - Modify: `docs/site/guides/labels.md` 336 + 337 + Brief. Show `defineLabels()` with `evaluate()`. Source from actual label files if they exist in templates, otherwise from CLI scaffolding output. 338 + 339 + **Step 1: Read current page and find examples** 340 + **Step 2: Rewrite the page** 341 + **Step 3: Commit** 342 + 343 + ```bash 344 + git add docs/site/guides/labels.md 345 + git commit -m "docs: rewrite labels guide" 346 + ``` 347 + 348 + ### Task 11: Rewrite opengraph guide 349 + 350 + **Files:** 351 + - Modify: `docs/site/guides/opengraph.md` 352 + 353 + Brief. Show `defineOG()` with Satori rendering. Source from template OG files. 354 + 355 + **Step 1: Read current page and find examples** 356 + **Step 2: Rewrite the page** 357 + **Step 3: Commit** 358 + 359 + ```bash 360 + git add docs/site/guides/opengraph.md 361 + git commit -m "docs: rewrite opengraph guide" 362 + ``` 363 + 364 + ### Task 12: Rewrite hooks guide 365 + 366 + **Files:** 367 + - Modify: `docs/site/guides/hooks.md` 368 + 369 + Brief. Show `defineHook('on-login', ...)` with `ensureRepo`. One example from templates. 370 + 371 + **Step 1: Rewrite the page** 372 + **Step 2: Commit** 373 + 374 + ```bash 375 + git add docs/site/guides/hooks.md 376 + git commit -m "docs: rewrite hooks guide" 377 + ``` 378 + 379 + --- 380 + 381 + ## Batch 5: Frontend section (3 new pages) 382 + 383 + ### Task 13: Write frontend/setup page 384 + 385 + **Files:** 386 + - Create: `docs/site/frontend/setup.md` 387 + 388 + Cover: 389 + - Vite plugin (`hatk()` in `vite.config.ts`) 390 + - `$hatk` and `$hatk/client` aliases 391 + - What `hatk.generated.ts` vs `hatk.generated.client.ts` contain 392 + - The `app/` directory convention (`svelte.config.js` files.src) 393 + - `hatk generate types` for regeneration after lexicon changes 394 + 395 + Source from `~/code/hatk-template-statusphere/vite.config.ts` and `svelte.config.js`. 396 + 397 + **Step 1: Write the page** 398 + **Step 2: Commit** 399 + 400 + ```bash 401 + git add docs/site/frontend/setup.md 402 + git commit -m "docs: add frontend setup page" 403 + ``` 404 + 405 + ### Task 14: Write frontend/data-loading page 406 + 407 + **Files:** 408 + - Create: `docs/site/frontend/data-loading.md` 409 + 410 + Cover: 411 + - `callXrpc()` from `$hatk/client` 412 + - Server load (`+page.server.ts`) — uses bridge directly 413 + - Universal load (`+page.ts`) — works both sides 414 + - `customFetch` parameter for SvelteKit's fetch deduplication 415 + - `getViewer()` for accessing current user in server code 416 + 417 + Source from `~/code/hatk-template-statusphere/app/routes/+page.server.ts` and `~/code/hatk-template-teal/app/routes/+page.ts`. 418 + 419 + **Step 1: Write the page** 420 + **Step 2: Commit** 421 + 422 + ```bash 423 + git add docs/site/frontend/data-loading.md 424 + git commit -m "docs: add frontend data loading page" 425 + ``` 426 + 427 + ### Task 15: Write frontend/mutations page 428 + 429 + **Files:** 430 + - Create: `docs/site/frontend/mutations.md` 431 + 432 + Cover: 433 + - Remote commands with `command('unchecked', ...)` 434 + - `callXrpc('dev.hatk.createRecord', ...)` and `deleteRecord` 435 + - Optimistic UI pattern 436 + 437 + Source from `~/code/hatk-template-statusphere/app/routes/status.remote.ts`. 438 + 439 + **Step 1: Write the page** 440 + **Step 2: Commit** 441 + 442 + ```bash 443 + git add docs/site/frontend/mutations.md 444 + git commit -m "docs: add frontend mutations page" 445 + ``` 446 + 447 + --- 448 + 449 + ## Batch 6: CLI and API reference updates 450 + 451 + ### Task 16: Update CLI pages 452 + 453 + **Files:** 454 + - Modify: `docs/site/cli/index.md` 455 + - Modify: `docs/site/cli/scaffold.md` 456 + - Modify: `docs/site/cli/development.md` 457 + - Modify: `docs/site/cli/testing.md` 458 + - Modify: `docs/site/cli/build.md` 459 + 460 + Read each page, update references: `config.yaml` → `hatk.config.ts`, remove DuckDB mentions, update command outputs to match current CLI. Add deployment info (SQLite/Railway) to `build.md`. Reference current CLI help output from `hatk --help`. 461 + 462 + **Step 1: Read all 5 CLI pages** 463 + **Step 2: Update each page** 464 + **Step 3: Commit** 465 + 466 + ```bash 467 + git add docs/site/cli/ 468 + git commit -m "docs: update CLI reference pages" 469 + ``` 470 + 471 + ### Task 17: Update API reference pages 472 + 473 + **Files:** 474 + - Modify: `docs/site/api/index.md` 475 + - Modify: `docs/site/api/records.md` 476 + - Modify: `docs/site/api/feeds.md` 477 + - Modify: `docs/site/api/search.md` 478 + - Modify: `docs/site/api/blobs.md` 479 + - Modify: `docs/site/api/preferences.md` 480 + - Modify: `docs/site/api/labels.md` 481 + 482 + Read each page, update to match current XRPC signatures. Remove DuckDB references. Update authentication section to reference session cookies instead of DPoP browser tokens. Add `customFetch` where relevant. 483 + 484 + **Step 1: Read all 7 API pages** 485 + **Step 2: Update each page** 486 + **Step 3: Commit** 487 + 488 + ```bash 489 + git add docs/site/api/ 490 + git commit -m "docs: update API reference pages" 491 + ``` 492 + 493 + --- 494 + 495 + ## Batch 7: Final cleanup and verify 496 + 497 + ### Task 18: Delete obsolete files and verify build 498 + 499 + **Files:** 500 + - Delete: `docs/site/guides/deployment.md` (if not already moved to cli/build.md) 501 + 502 + **Step 1: Check for any remaining dead links or old references** 503 + 504 + Run: `cd docs/site && grep -r "DuckDB\|duckdb\|config\.yaml\|OAuthClient\|@hatk/oauth-client\|TanStack\|tanstack" --include="*.md" .` 505 + Expected: No matches 506 + 507 + **Step 2: Build the docs site** 508 + 509 + Run: `cd docs/site && npx vitepress build` 510 + Expected: Build succeeds with no errors 511 + 512 + **Step 3: Commit any remaining cleanup** 513 + 514 + ```bash 515 + git add -A docs/site/ 516 + git commit -m "docs: final cleanup, remove dead references" 517 + ```
+161
docs/superpowers/specs/2026-03-18-docs-overhaul-design.md
··· 1 + # Docs Overhaul Design 2 + 3 + ## Goal 4 + 5 + Rewrite hatk documentation to reflect the current API surface (generated client helpers, SQLite-only, SvelteKit remote commands, `parseViewer`/`login`/`logout` from `$hatk/client`). Inspired by Nitro's docs style — project tree on landing page, code-heavy examples, progressive structure. 6 + 7 + ## Audience 8 + 9 + Web developers who may or may not know AT Protocol. Don't gate understanding behind AT Protocol knowledge. Explain concepts inline as they come up ("lexicons are schemas for your data — like Prisma models but for the AT Protocol"). Link to AT Protocol docs for deep dives. 10 + 11 + ## Framework 12 + 13 + Stay with VitePress. Content is the problem, not the tooling. 14 + 15 + --- 16 + 17 + ## Landing Page 18 + 19 + Hero with current tagline, then a project structure visualization: 20 + 21 + ``` 22 + my-app/ 23 + ├── app/ # SvelteKit frontend 24 + │ ├── routes/ 25 + │ │ ├── +layout.server.ts # parseViewer(cookies) 26 + │ │ └── +page.svelte # your UI 27 + │ └── lib/ 28 + ├── server/ # Backend 29 + │ ├── feeds/ # Feed generators 30 + │ │ └── recent.ts # defineFeed({ ... }) 31 + │ └── xrpc/ # Custom endpoints 32 + │ └── getProfile.ts # defineQuery('...', ...) 33 + ├── seeds/ 34 + │ └── seed.ts # Test fixtures 35 + ├── lexicons/ # AT Protocol schemas 36 + ├── hatk.config.ts # Configuration 37 + └── hatk.generated.ts # Auto-generated types 38 + ``` 39 + 40 + Below the tree, 4 feature cards: 41 + - **Typed end-to-end** — Lexicons generate TypeScript types for records, queries, feeds 42 + - **SQLite by default** — No external database, just works 43 + - **OAuth built-in** — AT Protocol auth with session cookies, no setup 44 + - **SvelteKit-first** — Full-stack with SSR, remote commands, typed XRPC calls 45 + 46 + CTA: "Get Started" 47 + 48 + --- 49 + 50 + ## Navigation Structure 51 + 52 + ### Top nav 53 + - Guide → /getting-started/quickstart 54 + - Frontend → /frontend/setup 55 + - CLI → /cli/ 56 + - API → /api/ 57 + 58 + ### Sidebar 59 + 60 + **Getting Started** (3 pages) 61 + - Quickstart 62 + - Project Structure 63 + - Configuration 64 + 65 + **Guides** (7 pages) 66 + - Feeds 67 + - XRPC Handlers 68 + - Auth & OAuth 69 + - Seeds 70 + - Labels 71 + - OpenGraph 72 + - Hooks 73 + 74 + **Frontend** (3 pages) 75 + - SvelteKit Setup 76 + - Data Loading 77 + - Mutations 78 + 79 + **CLI Reference** (5 pages) 80 + - Overview 81 + - Scaffolding 82 + - Development 83 + - Testing 84 + - Build & Deploy 85 + 86 + **API Reference** (7 pages) 87 + - Overview 88 + - Records 89 + - Feeds 90 + - Search 91 + - Blobs 92 + - Preferences 93 + - Labels 94 + 95 + --- 96 + 97 + ## Page Details 98 + 99 + ### Getting Started 100 + 101 + **Quickstart** — Zero to running app in under 2 minutes. 102 + 1. `npx hatk new my-app` 103 + 2. `cd my-app && npm run dev` 104 + 3. Open browser, see the app 105 + 4. Make a first change (edit a feed or add a record via admin) 106 + 107 + No AT Protocol preamble. Jump straight into doing. Explain concepts when they naturally arise. 108 + 109 + **Project Structure** — Expanded annotated tree. Each directory gets 2-3 sentences and a link to the relevant guide. Replaces current page which lists directories without connecting them to workflows. 110 + 111 + **Configuration** — `hatk.config.ts` reference. Each option with type, default, one-line description. Groups: server (relay, plc, port), database (engine, path), backfill (parallelism, signalCollections), oauth (issuer, scopes, clients). Minimal config example at top showing just the essentials. No DuckDB options. 112 + 113 + ### Guides 114 + 115 + All guides follow the same pattern: what it does, minimal example, then details. 116 + 117 + **Feeds** — `defineFeed()` with `generate` and `hydrate`. Statusphere "recent" feed as the example. Pagination with `ctx.paginate()`, hydration for author profiles. Most important guide. 118 + 119 + **XRPC Handlers** — `defineQuery()` and `defineProcedure()` with typed context (`Ctx<K>`). `ctx.ok()` for return type enforcement. `ctx.db.query()` for direct SQL, `ctx.lookup()` for cross-collection joins. 120 + 121 + **Auth & OAuth** — Rewritten from scratch. No `OAuthClient` class. Focus on: config (hatk.config.ts oauth section), built-in login/callback flow, `parseViewer(cookies)` in layouts, `login()`/`logout()` from `$hatk/client`, `getViewer()` in server code. Complete flow from config to working login. 122 + 123 + **Seeds** — `seed()` helper with `createAccount`, `createRecord`, `uploadBlob`. Complete seed file example. `hatk seed` and `hatk reset` commands. 124 + 125 + **Labels** — `defineLabels()` with `evaluate()`. Brief. 126 + 127 + **OpenGraph** — `defineOG()` with Satori. Brief. 128 + 129 + **Hooks** — `defineHook('on-login', ...)` with `ensureRepo`. One example. 130 + 131 + ### Frontend 132 + 133 + **SvelteKit Setup** — Vite plugin (`hatk()` in vite.config.ts), `$hatk` and `$hatk/client` aliases, what the generated files contain, the `app/` directory convention. `hatk generate types` for regeneration. 134 + 135 + **Data Loading** — `callXrpc()` from `$hatk/client`. Show in `+page.server.ts` (server-side bridge) and `+page.ts` (universal). `customFetch` for SvelteKit deduplication. `getViewer()` for current user in server code. 136 + 137 + **Mutations** — Remote commands with SvelteKit experimental remote functions. `command('unchecked', ...)` pattern. Creating/deleting records via `callXrpc('dev.hatk.createRecord', ...)`. Optimistic UI updates. 138 + 139 + ### CLI & API Reference 140 + 141 + Update to match current signatures. Remove DuckDB references. Add `customFetch` param, `parseViewer`, new generated client exports. 142 + 143 + --- 144 + 145 + ## Key Changes from Current Docs 146 + 147 + - Drop all DuckDB references (SQLite only) 148 + - Drop TanStack Query (use `callXrpc` directly) 149 + - Drop `OAuthClient` class (use generated `login`/`logout`/`parseViewer`) 150 + - Drop `api-client.md` guide (fold into Frontend section) 151 + - Frontend gets its own top-level section 152 + - Landing page gets project tree visualization 153 + - All examples sourced from hatk-template-statusphere and hatk-template-teal 154 + - Deployment guide updated for SQLite/Railway 155 + 156 + ## Source Material 157 + 158 + - `~/code/hatk-template-statusphere` — simple reference app 159 + - `~/code/hatk-template-teal` — complex reference app with feeds, bookmarks, search 160 + - `~/code/hatk-template-start` — minimal starter 161 + - `~/code/hatk/packages/hatk/src/cli.ts` — generated client output (auth helpers, parseViewer)