the statusphere demo reworked into a vite/react app in a monorepo

Add TUTORIAL.md

+769
+769
TUTORIAL.md
··· 1 + # Tutorial 2 + 3 + In this guide, we're going to build a **simple multi-user app** that publishes your current "status" as an emoji. 4 + 5 + At various points we will cover how to: 6 + 7 + - Signin via OAuth 8 + - Fetch information about users (profiles) 9 + - Listen to the network firehose for new data 10 + - Publish data on the user's account using a custom schema 11 + 12 + We're going to keep this light so you can quickly wrap your head around ATProto. There will be links with more information about each step. 13 + 14 + ## Where are we going? 15 + 16 + Data in the Atmosphere is stored on users' personal repos. It's almost like each user has their own website. Our goal is to aggregate data from the users into our SQLite DB. 17 + 18 + Think of our app like a Google. If Google's job was to say which emoji each website had under `/status.json`, then it would show something like: 19 + 20 + - `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.json` 21 + - `bsky.app` is feeling 🦋 according to `https://bsky.app/status.json` 22 + - `reddit.com` is feeling 🤓 according to `https://reddit.com/status.json` 23 + 24 + The Atmosphere works the same way, except we're going to check `at://` instead of `https://`. Each user has a data repo under an `at://` URL. We'll crawl all the `at://`s in the Atmosphere for all the `/status.json` records and aggregate them into our SQLite database. 25 + 26 + ## Step 1. Starting with our ExpressJS app 27 + 28 + Start by cloning the repo and installing packages. 29 + 30 + ```bash 31 + git clone TODO 32 + cd TODO 33 + npm i 34 + npm run dev # you can leave this running and it will auto-reload 35 + ``` 36 + 37 + Our repo is a regular Web app. We're rendering our HTML server-side like it's 1999. We also have a SQLite database that we're managing with [Kysley](#todo). 38 + 39 + Our starting stack: 40 + 41 + - Typescript 42 + - NodeJS web server ([express](#todo)) 43 + - SQLite database ([Kysley](#todo)) 44 + - Server-side rendering ([uhtml](#todo)) 45 + 46 + With each step we'll explain how our Web app taps into the Atmosphere. Refer to the codebase for more detailed code &mdash; again, this tutorial is going to keep it light and quick to digest. 47 + 48 + ## Step 2. Signing in with OAuth 49 + 50 + When somebody logs into our app, they'll give us read & write access to their personal `at://` repo. We'll use that to write the `status.json` record. 51 + 52 + We're going to accomplish this using OAuth ([spec](#todo)). You can find a [more extensive OAuth guide here](#todo), but for now just know that most of the OAuth flows are going to be handled for us using the [@atproto/oauth-client-node](#todo) library. This is the arrangement we're aiming toward: 53 + 54 + ``` 55 + ┌─App Server───────────────────┐ 56 + │ ┌─► Session store ◄┐ │ 57 + │ │ │ │ ┌───────────────┐ 58 + │ App code ──────►OAuth client─┼───►│ User's server │ 59 + └────▲─────────────────────────┘ └───────────────┘ 60 + ┌────┴──────────┐ 61 + │ Web browser │ 62 + └───────────────┘ 63 + ``` 64 + 65 + When the user logs in, the OAuth client will create a new session with their repo server and give us read/write access along with basic user info. 66 + 67 + Our login page just asks the user for their "handle," which is the domain name associated with their account. For [Bluesky](https://bsky.app) users, these tend to look like `alice.bsky.social`, but they can be any kind of domain (eg `alice.com`). 68 + 69 + ```html 70 + <!-- src/pages/login.ts --> 71 + <form action="/login" method="post" class="login-form"> 72 + <input 73 + type="text" 74 + name="handle" 75 + placeholder="Enter your handle (eg alice.bsky.social)" 76 + required 77 + /> 78 + <button type="submit">Log in</button> 79 + </form> 80 + ``` 81 + 82 + When they submit the form, we tell our OAuth client to initiate the authorization flow and then redirect the user to their server to complete the process. 83 + 84 + ```typescript 85 + /** src/routes.ts **/ 86 + // Login handler 87 + router.post( 88 + '/login', 89 + handler(async (req, res) => { 90 + // Initiate the OAuth flow 91 + const url = await oauthClient.authorize(handle) 92 + return res.redirect(url.toString()) 93 + }) 94 + ) 95 + ``` 96 + 97 + This is the same kind of SSO flow that Google or GitHub uses. The user will be asked for their password, then asked to confirm the session with your application. 98 + 99 + When that finishes, they'll be sent back to `/oauth/callback` on our Web app. The OAuth client stores the access tokens for the server, and then we attach their account's [DID](#todo) to their cookie-session. 100 + 101 + ```typescript 102 + /** src/routes.ts **/ 103 + // OAuth callback to complete session creation 104 + router.get( 105 + '/oauth/callback', 106 + handler(async (req, res) => { 107 + // Store the credentials 108 + const { agent } = await oauthClient.callback(params) 109 + 110 + // Attach the account DID to our user via a cookie 111 + const session = await getIronSession(req, res) 112 + session.did = agent.accountDid 113 + await session.save() 114 + 115 + // Send them back to the app 116 + return res.redirect('/') 117 + }) 118 + ) 119 + ``` 120 + 121 + With that, we're in business! We now have a session with the user's `at://` repo server and can use that to access their data. 122 + 123 + ## Step 3. Fetching the user's profile 124 + 125 + Why don't we learn something about our user? Let's start by getting the [Agent](#todo) object. The [Agent](#todo) is the client to the user's `at://` repo server. 126 + 127 + ```typescript 128 + /** src/routes.ts **/ 129 + async function getSessionAgent( 130 + req: IncomingMessage, 131 + res: ServerResponse<IncomingMessage>, 132 + ctx: AppContext 133 + ) { 134 + // Fetch the session from their cookie 135 + const session = await getIronSession(req, res) 136 + if (!session.did) return null 137 + 138 + // "Restore" the agent for the user 139 + return await ctx.oauthClient.restore(session.did).catch(async (err) => { 140 + ctx.logger.warn({ err }, 'oauth restore failed') 141 + await session.destroy() 142 + return null 143 + }) 144 + } 145 + ``` 146 + 147 + Users publish JSON records on their `at://` repos. In [Bluesky](https://bsky.app), they publish a "profile" record which looks like this: 148 + 149 + ```typescript 150 + interface ProfileRecord { 151 + displayName?: string // a human friendly name 152 + description?: string // a short bio 153 + avatar?: BlobRef // small profile picture 154 + banner?: BlobRef // banner image to put on profiles 155 + createdAt?: string // declared time this profile data was added 156 + // ... 157 + } 158 + ``` 159 + 160 + We're going to use the [Agent](#todo) to fetch this record to include in our app. 161 + 162 + ```typescript 163 + /** src/routes.ts **/ 164 + // Homepage 165 + router.get( 166 + '/', 167 + handler(async (req, res) => { 168 + // If the user is signed in, get an agent which communicates with their server 169 + const agent = await getSessionAgent(req, res, ctx) 170 + 171 + if (!agent) { 172 + // Serve the logged-out view 173 + return res.type('html').send(page(home())) 174 + } 175 + 176 + // Fetch additional information about the logged-in user 177 + const { data: profileRecord } = await agent.getRecord({ 178 + repo: agent.accountDid, // our user's repo 179 + collection: 'app.bsky.actor.profile', // the bluesky profile record type 180 + rkey: 'self', // the record's name 181 + }) 182 + 183 + // Serve the logged-in view 184 + return res 185 + .type('html') 186 + .send(page(home({ profile: profileRecord.value || {} }))) 187 + }) 188 + ) 189 + ``` 190 + 191 + With that data, we can give a nice personalized welcome banner for our user: 192 + 193 + ```html 194 + <!-- pages/home.ts --> 195 + <div class="card"> 196 + ${profile 197 + ? html`<form action="/logout" method="post" class="session-form"> 198 + <div> 199 + Hi, <strong>${profile.displayName || 'friend'}</strong>. 200 + What's your status today? 201 + </div> 202 + <div> 203 + <button type="submit">Log out</button> 204 + </div> 205 + </form>` 206 + : html`<div class="session-form"> 207 + <div><a href="/login">Log in</a> to set your status!</div> 208 + <div> 209 + <a href="/login" class="button">Log in</a> 210 + </div> 211 + </div>`} 212 + </div> 213 + ``` 214 + 215 + ## Step 4. Reading & writing records 216 + 217 + You can think of the user repositories as collections of JSON records: 218 + 219 + ``` 220 + ┌────────┐ 221 + ┌───| record │ 222 + ┌────────────┐ │ └────────┘ 223 + ┌───| collection |◄─┤ ┌────────┐ 224 + ┌──────┐ │ └────────────┘ └───| record │ 225 + │ repo |◄──┤ └────────┘ 226 + └──────┘ │ ┌────────────┐ ┌────────┐ 227 + └───┤ collection |◄─────| record │ 228 + └────────────┘ └────────┘ 229 + ``` 230 + 231 + Let's look again at how we read the "profile" record: 232 + 233 + ```typescript 234 + await agent.getRecord({ 235 + repo: agent.accountDid, // The user 236 + collection: 'app.bsky.actor.profile', // The collection 237 + rkey: 'self', // The record name 238 + }) 239 + ``` 240 + 241 + We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen: 242 + 243 + ```typescript 244 + await agent.putRecord({ 245 + repo: agent.accountDid, // The user 246 + collection: 'com.example.status', // The collection 247 + rkey: 'self', // The record name 248 + record: { // The record value 249 + status: "👍", 250 + updatedAt: new Date().toISOString() 251 + } 252 + }) 253 + ``` 254 + 255 + Our `POST /status` route is going to use this API to publish the user's status to their repo. 256 + 257 + ```typescript 258 + /** src/routes.ts **/ 259 + // "Set status" handler 260 + router.post( 261 + '/status', 262 + handler(async (req, res) => { 263 + // If the user is signed in, get an agent which communicates with their server 264 + const agent = await getSessionAgent(req, res, ctx) 265 + if (!agent) { 266 + return res.status(401).json({ error: 'Session required' }) 267 + } 268 + 269 + // Construct their status record 270 + const record = { 271 + $type: 'com.example.status', 272 + status: req.body?.status, 273 + updatedAt: new Date().toISOString(), 274 + } 275 + 276 + try { 277 + // Write the status record to the user's repository 278 + await agent.putRecord({ 279 + repo: agent.accountDid, 280 + collection: 'com.example.status', 281 + rkey: 'self', 282 + record, 283 + }) 284 + } catch (err) { 285 + ctx.logger.warn({ err }, 'failed to write record') 286 + return res.status(500).json({ error: 'Failed to write record' }) 287 + } 288 + 289 + res.status(200).json({}) 290 + }) 291 + ) 292 + ``` 293 + 294 + Now in our homepage we can list out the status buttons: 295 + 296 + ```html 297 + <!-- src/pages/home.ts --> 298 + <div class="status-options"> 299 + ${['👍', '🦋', '🥳', /*...*/].map(status => html` 300 + <div class="status-option" data-value="${status}"> 301 + ${status} 302 + </div>` 303 + )} 304 + </div> 305 + ``` 306 + 307 + And write some client-side javascript to submit the status on click: 308 + 309 + ```javascript 310 + /* src/pages/public/home.js */ 311 + Array.from(document.querySelectorAll('.status-option'), (el) => { 312 + el.addEventListener('click', async (ev) => { 313 + const res = await fetch('/status', { 314 + method: 'POST', 315 + headers: { 'content-type': 'application/json' }, 316 + body: JSON.stringify({ status: el.dataset.value }), 317 + }) 318 + const body = await res.json() 319 + if (!body?.error) { 320 + location.reload() 321 + } 322 + }) 323 + }) 324 + ``` 325 + 326 + ## Step 5. Creating a custom "status" schema 327 + 328 + The collections are typed, meaning that they have a defined schema. The `app.bsky.actor.profile` type definition [can be found here](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/actor/profile.json). 329 + 330 + Anybody can create a new schema using the [Lexicon](#todo) language, which is very similar to [JSON-Schema](#todo). The schemas use [reverse-DNS IDs](#todo) which indicate ownership, but for this demo app we're going to use `com.example` which is safe for non-production software. 331 + 332 + > ### Why create a schema? 333 + > 334 + > Schemas help other applications understand the data your app is creating. By publishing your schemas, you enable compatibility and reduce the chances of bad data affecting your app. 335 + 336 + Let's create our schema in the `/lexicons` folder of our codebase. You can [read more about how to define schemas here](#todo). 337 + 338 + ```json 339 + /* lexicons/status.json */ 340 + { 341 + "lexicon": 1, 342 + "id": "com.example.status", 343 + "defs": { 344 + "main": { 345 + "type": "record", 346 + "key": "literal:self", 347 + "record": { 348 + "type": "object", 349 + "required": ["status", "updatedAt"], 350 + "properties": { 351 + "status": { 352 + "type": "string", 353 + "minLength": 1, 354 + "maxGraphemes": 1, 355 + "maxLength": 32 356 + }, 357 + "updatedAt": { 358 + "type": "string", 359 + "format": "datetime" 360 + } 361 + } 362 + } 363 + } 364 + } 365 + } 366 + ``` 367 + 368 + Now let's run some code-generation using our schema: 369 + 370 + ```bash 371 + ./node_modules/.bin/lex gen-server ./src/lexicon ./lexicons/* 372 + ``` 373 + 374 + This will produce Typescript interfaces as well as runtime validation functions that we can use in our `POST /status` route: 375 + 376 + ```typescript 377 + /** src/routes.ts **/ 378 + import * as Status from '#/lexicon/types/com/example/status' 379 + // ... 380 + // "Set status" handler 381 + router.post( 382 + '/status', 383 + handler(async (req, res) => { 384 + // ... 385 + 386 + // Construct & validate their status record 387 + const record = { 388 + $type: 'com.example.status', 389 + status: req.body?.status, 390 + updatedAt: new Date().toISOString(), 391 + } 392 + if (!Status.validateRecord(record).success) { 393 + return res.status(400).json({ error: 'Invalid status' }) 394 + } 395 + 396 + // ... 397 + }) 398 + ) 399 + ``` 400 + 401 + ## Step 6. Listening to the firehose 402 + 403 + So far, we have: 404 + 405 + - Logged in via OAuth 406 + - Created a custom schema 407 + - Read & written records for the logged in user 408 + 409 + Now we want to fetch the status records from other users. 410 + 411 + Remember how we referred to our app as being like a Google, crawling around the repos to get their records? One advantage we have in the AT Protocol is that each repo publishes an event log of their updates. 412 + 413 + ``` 414 + ┌──────┐ 415 + │ REPO │ Event stream 416 + ├──────┘ 417 + │ ┌───────────────────────────────────────────┐ 418 + ├───┼ 1 PUT /com.example.status/self │ 419 + │ └───────────────────────────────────────────┘ 420 + │ ┌───────────────────────────────────────────┐ 421 + ├───┼ 2 DEL /app.bsky.feed.post/3l244rmrxjx2v │ 422 + │ └───────────────────────────────────────────┘ 423 + │ ┌───────────────────────────────────────────┐ 424 + ├───┼ 3 PUT /app.bsky.actor/self │ 425 + ▼ └───────────────────────────────────────────┘ 426 + ``` 427 + 428 + Using a [Relay service](#todo) we can listen to an aggregated firehose of these events across all users in the network. In our case what we're looking for are valid `com.example.status` records. 429 + 430 + 431 + ```typescript 432 + /** src/firehose.ts **/ 433 + import * as Status from '#/lexicon/types/com/example/status' 434 + // ... 435 + const firehose = new Firehose({}) 436 + 437 + for await (const evt of firehose.run()) { 438 + // Watch for write events 439 + if (evt.event === 'create' || evt.event === 'update') { 440 + const record = evt.record 441 + 442 + // If the write is a valid status update 443 + if ( 444 + evt.collection === 'com.example.status' && 445 + Status.isRecord(record) && 446 + Status.validateRecord(record).success 447 + ) { 448 + // Store the status 449 + // TODO 450 + } 451 + } 452 + } 453 + ``` 454 + 455 + Let's create a SQLite table to store these statuses: 456 + 457 + ```typescript 458 + /** src/db.ts **/ 459 + // Create our statuses table 460 + await db.schema 461 + .createTable('status') 462 + .addColumn('authorDid', 'varchar', (col) => col.primaryKey()) 463 + .addColumn('status', 'varchar', (col) => col.notNull()) 464 + .addColumn('updatedAt', 'varchar', (col) => col.notNull()) 465 + .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 466 + .execute() 467 + ``` 468 + 469 + Now we can write these statuses into our database as they arrive from the firehose: 470 + 471 + ```typescript 472 + /** src/firehose.ts **/ 473 + // If the write is a valid status update 474 + if ( 475 + evt.collection === 'com.example.status' && 476 + Status.isRecord(record) && 477 + Status.validateRecord(record).success 478 + ) { 479 + // Store the status in our SQLite 480 + await db 481 + .insertInto('status') 482 + .values({ 483 + authorDid: evt.author, 484 + status: record.status, 485 + updatedAt: record.updatedAt, 486 + indexedAt: new Date().toISOString(), 487 + }) 488 + .onConflict((oc) => 489 + oc.column('authorDid').doUpdateSet({ 490 + status: record.status, 491 + updatedAt: record.updatedAt, 492 + indexedAt: new Date().toISOString(), 493 + }) 494 + ) 495 + .execute() 496 + } 497 + ``` 498 + 499 + You can almost think of information flowing in a loop: 500 + 501 + ``` 502 + ┌─────Repo put─────┐ 503 + │ ▼ 504 + ┌──────┴─────┐ ┌───────────┐ 505 + │ App server │ │ User repo │ 506 + └────────────┘ └─────┬─────┘ 507 + ▲ │ 508 + └────Event log─────┘ 509 + ``` 510 + 511 + Why read from the event log? Because there are other apps in the network that will write the records we're interested in. By subscribing to the event log, we ensure that we catch all the data we're interested in -- including data published by other apps. 512 + 513 + ## Step 7. Listing the latest statuses 514 + 515 + Now that we have statuses populating our SQLite, we can produce a timeline of status updates by users. We also use a [DID](#todo)-to-handle resolver so we can show a nice username with the statuses: 516 + 517 + ```typescript 518 + /** src/routes.ts **/ 519 + // Homepage 520 + router.get( 521 + '/', 522 + handler(async (req, res) => { 523 + // ... 524 + 525 + // Fetch data stored in our SQLite 526 + const statuses = await db 527 + .selectFrom('status') 528 + .selectAll() 529 + .orderBy('indexedAt', 'desc') 530 + .limit(10) 531 + .execute() 532 + 533 + // Map user DIDs to their domain-name handles 534 + const didHandleMap = await resolver.resolveDidsToHandles( 535 + statuses.map((s) => s.authorDid) 536 + ) 537 + 538 + // ... 539 + }) 540 + ) 541 + ``` 542 + 543 + Our HTML can now list these status records: 544 + 545 + ```html 546 + <!-- src/pages/home.ts --> 547 + ${statuses.map((status, i) => { 548 + const handle = didHandleMap[status.authorDid] || status.authorDid 549 + const date = ts(status) 550 + return html` 551 + <div class="status-line"> 552 + <div> 553 + <div class="status">${status.status}</div> 554 + </div> 555 + <div class="desc"> 556 + <a class="author" href="https://bsky.app/profile/${handle}">@${handle}</a> 557 + was feeling ${status.status} on ${status.indexedAt}. 558 + </div> 559 + </div> 560 + ` 561 + })} 562 + ``` 563 + 564 + ## Step 8. Optimistic updates 565 + 566 + As a final optimization, let's introduce "optimistic updates." Remember the information flow loop with the repo write and the event log? Since we're updating our users' repos locally, we can short-circuit that flow to our own database: 567 + 568 + ``` 569 + ┌───Repo put──┬──────┐ 570 + │ │ ▼ 571 + ┌──────┴─────┐ │ ┌───────────┐ 572 + │ App server │◄──────┘ │ User repo │ 573 + └────────────┘ └───┬───────┘ 574 + ▲ │ 575 + └────Event log───────┘ 576 + ``` 577 + 578 + This is an important optimization to make, because it ensures that the user sees their own changes while using your app. When the event eventually arrives from the firehose, we just discard it since we already have it saved locally. 579 + 580 + To do this, we just update `POST /status` to include an additional write to our SQLite DB: 581 + 582 + ```typescript 583 + /** src/routes.ts **/ 584 + // "Set status" handler 585 + router.post( 586 + '/status', 587 + handler(async (req, res) => { 588 + // ... 589 + 590 + try { 591 + // Write the status record to the user's repository 592 + await agent.putRecord({ 593 + repo: agent.accountDid, 594 + collection: 'com.example.status', 595 + rkey: 'self', 596 + record, 597 + }) 598 + } catch (err) { 599 + ctx.logger.warn({ err }, 'failed to write record') 600 + return res.status(500).json({ error: 'Failed to write record' }) 601 + } 602 + 603 + try { 604 + // Optimistically update our SQLite <-- HERE! 605 + await ctx.db 606 + .insertInto('status') 607 + .values({ 608 + authorDid: agent.accountDid, 609 + status: record.status, 610 + updatedAt: record.updatedAt, 611 + indexedAt: new Date().toISOString(), 612 + }) 613 + .onConflict((oc) => 614 + oc.column('authorDid').doUpdateSet({ 615 + status: record.status, 616 + updatedAt: record.updatedAt, 617 + indexedAt: new Date().toISOString(), 618 + }) 619 + ) 620 + .execute() 621 + } catch (err) { 622 + ctx.logger.warn( 623 + { err }, 624 + 'failed to update computed view; ignoring as it should be caught by the firehose' 625 + ) 626 + } 627 + 628 + res.status(200).json({}) 629 + }) 630 + ) 631 + ``` 632 + 633 + You'll notice this code looks almost exactly like what we're doing in `firehose.ts`. 634 + 635 + ## Next steps 636 + 637 + TODO 638 + 639 + 640 + 641 + --- 642 + 643 + Let's create the client during the server init: 644 + 645 + ```typescript 646 + /** index.ts **/ 647 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 648 + 649 + // static async create() { 650 + // ... 651 + 652 + const publicUrl = env.PUBLIC_URL 653 + const url = publicUrl || `http://127.0.0.1:${env.PORT}` 654 + const oauthClient = new NodeOAuthClient({ 655 + clientMetadata: { 656 + client_name: 'AT Protocol Express App', 657 + client_id: publicUrl 658 + ? `${url}/client-metadata.json` 659 + : `http://localhost?redirect_uri=${encodeURIComponent(`${url}/oauth/callback`)}`, 660 + client_uri: url, 661 + redirect_uris: [`${url}/oauth/callback`], 662 + scope: 'profile offline_access', 663 + grant_types: ['authorization_code', 'refresh_token'], 664 + response_types: ['code'], 665 + application_type: 'web', 666 + token_endpoint_auth_method: 'none', 667 + dpop_bound_access_tokens: true, 668 + }, 669 + stateStore: new StateStore(db), 670 + sessionStore: new SessionStore(db), 671 + }) 672 + 673 + // ... 674 + // } 675 + ``` 676 + 677 + There's quite a bit of configuration which is [explained in the OAuth guide](#todo). We host that config at `/client-metadata.json` as part of the OAuth flow. 678 + 679 + ```typescript 680 + /** src/routes.ts **/ 681 + 682 + // OAuth metadata 683 + router.get( 684 + '/client-metadata.json', 685 + handler((_req, res) => { 686 + return res.json(oauthClient.clientMetadata) 687 + }) 688 + ) 689 + ``` 690 + 691 + --- 692 + 693 + 694 + 695 + We're going to need to track two kinds of information: 696 + 697 + - **OAuth State**. This is information about login flows that are in-progress. 698 + - **OAuth Sessions**. This is the active session data. 699 + 700 + The `oauth-client-node` library handles most of this for us, but we need to create some tables in our SQLite to store it. Let's update `/src/db.ts` for this. 701 + 702 + ```typescript 703 + // ... 704 + // Types 705 + 706 + export type DatabaseSchema = { 707 + auth_session: AuthSession 708 + auth_state: AuthState 709 + } 710 + 711 + export type AuthSession = { 712 + key: string 713 + session: string // JSON 714 + } 715 + 716 + export type AuthState = { 717 + key: string 718 + state: string // JSON 719 + } 720 + 721 + // Migrations 722 + 723 + migrations['001'] = { 724 + async up(db: Kysely<unknown>) { 725 + await db.schema 726 + .createTable('auth_session') 727 + .addColumn('key', 'varchar', (col) => col.primaryKey()) 728 + .addColumn('session', 'varchar', (col) => col.notNull()) 729 + .execute() 730 + await db.schema 731 + .createTable('auth_state') 732 + .addColumn('key', 'varchar', (col) => col.primaryKey()) 733 + .addColumn('state', 'varchar', (col) => col.notNull()) 734 + .execute() 735 + }, 736 + async down(db: Kysely<unknown>) { 737 + await db.schema.dropTable('auth_state').execute() 738 + await db.schema.dropTable('auth_session').execute() 739 + }, 740 + } 741 + 742 + // ... 743 + ``` 744 + 745 + 746 + ---- 747 + 748 + 749 + Data in the Atmosphere is stored on users' personal servers. It's almost like each user has their own website. Our goal is to aggregate data from the users into our SQLite. 750 + 751 + Think of our app like a Google. If Google's job was to say which emoji each website had under `/status.txt`, then it would show something like: 752 + 753 + - `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.txt` 754 + - `bsky.app` is feeling 🦋 according to `https://bsky.app/status.txt` 755 + - `reddit.com` is feeling 🤓 according to `https://reddit.com/status.txt` 756 + 757 + The Atmosphere works the same way, except we're going to check `at://nytimes.com/com.example.status/self`. Literally, that's it! Each user has a domain, and each record gets published under an atproto URL. 758 + 759 + ``` 760 + AT Protocol 761 + 762 + at://nytimes.com/com.example.status/self 763 + ▲ ▲ ▲ 764 + The user The data type The record name 765 + ``` 766 + 767 + When somebody logs into our app, they'll give read & write access to their personal `at://`. We'll use that to write the `/com.example.status/self` record. Then we'll crawl the Atmosphere for all the other `/com.example.status/self` records, and aggregate them into our SQLite database for fast reads. 768 + 769 + Believe it or not, that's how most apps on the Atmosphere are built, including [Bluesky](#todo).