an atproto based link aggregator

Add optimistic UI, hot ranking, and split database architecture

- Optimistic UI for posts and comments with pending stores
- Poll for confirmation every 2s while items are pending
- Filter duplicates when pending items appear in real data
- Hot score ranking for posts (homepage) and comments (post detail)
- Extract ranking algorithm to shared $lib/utils/ranking.ts
- Split database architecture: contentDb (LiteFS) + localDb (auth/votes)
- Separate drizzle configs for content and local databases
- Add dev:ingester script for local development
- Update CLAUDE.md and implementation plan with local dev instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+2114 -490
+76 -22
CLAUDE.md
··· 1 1 # papili.one 2 2 3 - ATProto-based link aggregator built with SvelteKit + Cloudflare. 3 + ATProto-based link aggregator built with SvelteKit + Fly.io. 4 4 5 5 ## Commands 6 6 7 7 ```bash 8 8 # Package manager: pnpm (NOT npm) 9 9 pnpm install # Install dependencies 10 - pnpm dev # Start dev server (no D1, basic testing) 11 - pnpm dev:wrangler # Start dev with D1 bindings (for auth testing) 10 + pnpm dev # Start dev server 12 11 pnpm build # Build for production 12 + pnpm start # Run production build 13 13 pnpm preview # Preview production build 14 14 pnpm check # Type check 15 15 pnpm test # Run tests once ··· 17 17 pnpm lint # Check formatting + linting 18 18 pnpm format # Auto-format code 19 19 20 - # Database (D1) 21 - pnpm db:local # Create local D1 tables 20 + # Database (SQLite locally) 22 21 pnpm db:push # Push schema to database 23 22 pnpm db:generate # Generate migrations 24 23 pnpm db:migrate # Run migrations 25 24 pnpm db:studio # Open Drizzle Studio 26 25 27 - # Cloudflare 28 - pnpm wrangler d1 create papili-db # Create D1 database (first time, needs CF auth) 29 - pnpm wrangler pages deploy # Deploy to Cloudflare Pages 26 + # Lexicons 27 + pnpm lex:build # Generate TypeScript from lexicons 30 28 ``` 31 29 32 - ## Local Development with Auth 30 + ## Architecture 31 + 32 + - **Web app**: SvelteKit with adapter-node, deployed to Fly.io 33 + - **Ingester**: Separate process on Fly.io (LiteFS primary) 34 + - **Database**: Split SQLite architecture with LiteFS: 35 + - **Content DB** (`content.db`): Posts, comments, accounts - written by ingester, replicated to webapp via LiteFS 36 + - **Local DB** (`local.db`): Auth state, sessions, votes - webapp only 37 + 38 + ### Data Flow 39 + 1. User submits post/comment → Webapp writes to ATProto (PDS) 40 + 2. Jetstream ingester picks up event → Writes to content DB (LiteFS primary) 41 + 3. LiteFS replicates to webapp machines → Content visible to users 42 + 4. Votes stored locally in webapp's local DB (not replicated) 33 43 34 - ATProto OAuth requires `127.0.0.1` (not localhost). To test auth locally: 44 + ## Local Development 35 45 36 - 1. Run `pnpm db:local` to create tables 37 - 2. Run `pnpm dev:wrangler` to start with D1 bindings 38 - 3. Visit `http://127.0.0.1:5173` (NOT localhost) 46 + Local dev requires **two processes**: the SvelteKit dev server and the Jetstream ingester. 39 47 40 - ## Dev Server Management 48 + ATProto OAuth requires `127.0.0.1` (not localhost). 41 49 42 - Use tmux to manage the dev server (not background shell processes): 50 + ### Quick Start 43 51 44 52 ```bash 45 - # Start dev server in tmux session 46 - tmux new-session -d -s papili 'pnpm dev' 53 + # Terminal 1: Start dev server (bound to 127.0.0.1 for OAuth) 54 + pnpm dev --host 127.0.0.1 47 55 48 - # Check server output 56 + # Terminal 2: Start ingester (picks up posts from Jetstream) 57 + pnpm dev:ingester 58 + ``` 59 + 60 + Visit `http://127.0.0.1:5173` (NOT localhost) 61 + 62 + ### Using tmux (recommended) 63 + 64 + ```bash 65 + # Start both services in tmux sessions 66 + tmux new-session -d -s papili 'pnpm dev --host 127.0.0.1' 67 + tmux new-session -d -s ingester 'pnpm dev:ingester' 68 + 69 + # Check outputs 49 70 tmux capture-pane -t papili -p 71 + tmux capture-pane -t ingester -p 50 72 51 - # Kill dev server 73 + # Kill sessions 52 74 tmux kill-session -t papili 75 + tmux kill-session -t ingester 76 + # Or kill all: tmux kill-server 53 77 ``` 54 78 55 - The user may have an existing tmux session running the dev server. 79 + ### Database Setup 80 + 81 + First time or after schema changes: 82 + 83 + ```bash 84 + pnpm db:push # Push schema to both content.db and local.db 85 + ``` 56 86 57 87 ## Key Files 58 88 59 - - `wrangler.toml` - Cloudflare config (D1 binding) 60 - - `src/lib/server/db/schema.ts` - Database schema 89 + - `src/lib/server/db/content-schema.ts` - Content DB schema (posts, comments, accounts) 90 + - `src/lib/server/db/local-schema.ts` - Local DB schema (auth, votes) 91 + - `src/lib/server/db/index.ts` - Database connections (contentDb, localDb) 92 + - `src/ingester/main.ts` - Standalone ingester entry point 93 + - `src/ingester/handler.ts` - Jetstream event handler 94 + - `fly.toml` - Webapp Fly.io config 95 + - `fly.ingester.toml` - Ingester Fly.io config 96 + - `litefs.yml` - LiteFS config for webapp (replica) 97 + - `litefs.ingester.yml` - LiteFS config for ingester (primary) 61 98 - `docs/implementation_plan.md` - Full implementation roadmap 99 + 100 + ## Deployment 101 + 102 + ```bash 103 + # Deploy webapp (LiteFS replica) 104 + fly deploy 105 + 106 + # Deploy ingester (LiteFS primary) - separate app 107 + fly deploy -c fly.ingester.toml 108 + 109 + # First time setup: 110 + # 1. Create persistent volume for local DB 111 + fly volumes create papili_data --size 1 112 + 113 + # 2. Enable LiteFS (Consul lease) 114 + fly consul attach 115 + ``` 62 116 63 117 --- 64 118
+14 -5
Dockerfile
··· 15 15 RUN pnpm build 16 16 RUN pnpm prune --prod 17 17 18 - # Production stage 18 + # Production stage with LiteFS 19 + FROM flyio/litefs:0.5 AS litefs 20 + 19 21 FROM base AS production 20 22 23 + # Install LiteFS dependencies and copy binary 24 + RUN apt-get update && apt-get install -y ca-certificates fuse3 sqlite3 && rm -rf /var/lib/apt/lists/* 25 + COPY --from=litefs /usr/local/bin/litefs /usr/local/bin/litefs 26 + 21 27 COPY --from=build /app/build ./build 22 28 COPY --from=build /app/node_modules ./node_modules 23 29 COPY --from=build /app/package.json ./ 24 30 COPY --from=build /app/drizzle ./drizzle 31 + COPY litefs.yml /etc/litefs.yml 25 32 26 - # Create data directory (will be mounted from Fly volume) 27 - RUN mkdir -p /data 33 + # Create directories 34 + RUN mkdir -p /data /litefs /var/lib/litefs 28 35 29 36 ENV NODE_ENV=production 30 37 ENV PORT=3000 31 - ENV DATABASE_PATH=/data/papili.db 38 + # Content DB from LiteFS replica, local DB for auth/votes 39 + ENV CONTENT_DB_PATH=/litefs/content.db 40 + ENV LOCAL_DB_PATH=/data/local.db 32 41 33 42 EXPOSE 3000 34 - CMD ["node", "build"] 43 + ENTRYPOINT ["litefs", "mount"]
+43
Dockerfile.ingester
··· 1 + # Ingester Dockerfile 2 + # Runs as standalone process on the LiteFS primary machine 3 + 4 + FROM node:22-slim AS base 5 + ENV PNPM_HOME="/pnpm" 6 + ENV PATH="$PNPM_HOME:$PATH" 7 + RUN corepack enable 8 + 9 + WORKDIR /app 10 + 11 + # Build stage 12 + FROM base AS build 13 + 14 + COPY package.json pnpm-lock.yaml ./ 15 + RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 16 + 17 + COPY . . 18 + # Build lexicons (needed for type validation) 19 + RUN pnpm lex:build 20 + 21 + # Production stage with LiteFS 22 + FROM flyio/litefs:0.5 AS litefs 23 + 24 + FROM base AS production 25 + 26 + # Install LiteFS dependencies and copy binary 27 + RUN apt-get update && apt-get install -y ca-certificates fuse3 sqlite3 && rm -rf /var/lib/apt/lists/* 28 + COPY --from=litefs /usr/local/bin/litefs /usr/local/bin/litefs 29 + 30 + COPY --from=build /app/node_modules ./node_modules 31 + COPY --from=build /app/package.json ./ 32 + COPY --from=build /app/src ./src 33 + COPY --from=build /app/drizzle ./drizzle 34 + COPY litefs.ingester.yml /etc/litefs.yml 35 + 36 + # Create directories 37 + RUN mkdir -p /litefs /var/lib/litefs 38 + 39 + # Environment 40 + ENV NODE_ENV=production 41 + ENV CONTENT_DB_PATH=/litefs/content.db 42 + 43 + ENTRYPOINT ["litefs", "mount"]
-10
drizzle.config.ts
··· 1 - import { defineConfig } from 'drizzle-kit'; 2 - 3 - export default defineConfig({ 4 - schema: './src/lib/server/db/schema.ts', 5 - out: './drizzle', 6 - dialect: 'sqlite', 7 - dbCredentials: { 8 - url: process.env.DATABASE_PATH || './data/papili.db' 9 - } 10 - });
+10
drizzle.content.config.ts
··· 1 + import { defineConfig } from 'drizzle-kit'; 2 + 3 + export default defineConfig({ 4 + schema: './src/lib/server/db/content-schema.ts', 5 + out: './drizzle/content', 6 + dialect: 'sqlite', 7 + dbCredentials: { 8 + url: process.env.CONTENT_DB_PATH || './data/content.db' 9 + } 10 + });
+10
drizzle.local.config.ts
··· 1 + import { defineConfig } from 'drizzle-kit'; 2 + 3 + export default defineConfig({ 4 + schema: './src/lib/server/db/local-schema.ts', 5 + out: './drizzle/local', 6 + dialect: 'sqlite', 7 + dbCredentials: { 8 + url: process.env.LOCAL_DB_PATH || './data/local.db' 9 + } 10 + });
-27
drizzle/0001_initial.sql
··· 1 - -- Initial schema for papili.one 2 - -- Auth tables 3 - 4 - CREATE TABLE IF NOT EXISTS auth_state ( 5 - key TEXT PRIMARY KEY, 6 - state TEXT NOT NULL, 7 - created_at TEXT NOT NULL 8 - ); 9 - 10 - CREATE TABLE IF NOT EXISTS auth_session ( 11 - key TEXT PRIMARY KEY, 12 - session TEXT NOT NULL, 13 - created_at TEXT NOT NULL, 14 - updated_at TEXT NOT NULL 15 - ); 16 - 17 - CREATE TABLE IF NOT EXISTS accounts ( 18 - did TEXT PRIMARY KEY, 19 - handle TEXT, 20 - active INTEGER NOT NULL DEFAULT 1, 21 - status TEXT, 22 - updated_at TEXT NOT NULL 23 - ); 24 - 25 - -- Indexes 26 - CREATE INDEX IF NOT EXISTS idx_auth_state_created ON auth_state(created_at); 27 - CREATE INDEX IF NOT EXISTS idx_accounts_handle ON accounts(handle);
+25
fly.ingester.toml
··· 1 + # Ingester configuration for Fly.io 2 + # This runs as the LiteFS primary - it's the only machine that writes to the content DB 3 + 4 + app = "papili-ingester" 5 + primary_region = "sjc" 6 + 7 + [build] 8 + dockerfile = "Dockerfile.ingester" 9 + 10 + # No HTTP service needed - this is a background worker 11 + [processes] 12 + ingester = "pnpm exec tsx src/ingester/main.ts" 13 + 14 + [[vm]] 15 + size = "shared-cpu-1x" 16 + memory = "256mb" 17 + processes = ["ingester"] 18 + 19 + # LiteFS configuration - this machine is the primary 20 + [mounts] 21 + source = "litefs" 22 + destination = "/litefs" 23 + 24 + [env] 25 + CONTENT_DB_PATH = "/litefs/content.db"
+10 -1
fly.toml
··· 5 5 6 6 [env] 7 7 NODE_ENV = 'production' 8 - DATABASE_PATH = '/data/papili.db' 8 + # Content DB is read from LiteFS replica (synced from ingester primary) 9 + CONTENT_DB_PATH = '/litefs/content.db' 10 + # Local DB is webapp-only (auth, votes) 11 + LOCAL_DB_PATH = '/data/local.db' 9 12 10 13 [http_service] 11 14 internal_port = 3000 ··· 20 23 cpu_kind = 'shared' 21 24 cpus = 1 22 25 26 + # Local data volume for webapp-only DB (auth, votes) 23 27 [mounts] 24 28 source = 'papili_data' 25 29 destination = '/data' 30 + 31 + # LiteFS mount for content DB replica 32 + [[mounts]] 33 + source = 'litefs' 34 + destination = '/litefs'
+3 -3
lexicons/one/papili/post.json
··· 5 5 "main": { 6 6 "type": "record", 7 7 "key": "tid", 8 - "description": "A link submission to papili.one", 8 + "description": "A post on papili.one - either a link submission or text post", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["url", "title", "createdAt"], 11 + "required": ["title", "createdAt"], 12 12 "properties": { 13 13 "url": { 14 14 "type": "string", 15 15 "format": "uri", 16 - "description": "The URL being submitted" 16 + "description": "Optional URL for link submissions" 17 17 }, 18 18 "title": { 19 19 "type": "string",
+19
litefs.ingester.yml
··· 1 + # LiteFS configuration for the ingester (primary) 2 + # This machine is the only writer for the content database 3 + 4 + fuse: 5 + dir: "/litefs" 6 + 7 + data: 8 + dir: "/var/lib/litefs" 9 + 10 + lease: 11 + type: "consul" 12 + advertise-url: "http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202" 13 + candidate: true 14 + consul: 15 + url: "${FLY_CONSUL_URL}" 16 + key: "papili-one/primary" 17 + 18 + exec: 19 + - cmd: "pnpm exec tsx src/ingester/main.ts"
+18
litefs.yml
··· 1 + # LiteFS configuration for the webapp (replica) 2 + # This machine reads from the ingester primary 3 + 4 + fuse: 5 + dir: "/litefs" 6 + 7 + data: 8 + dir: "/var/lib/litefs" 9 + 10 + lease: 11 + type: "consul" 12 + advertise-url: "http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202" 13 + consul: 14 + url: "${FLY_CONSUL_URL}" 15 + key: "papili-one/primary" 16 + 17 + exec: 18 + - cmd: "node build"
+17 -5
package.json
··· 10 10 }, 11 11 "scripts": { 12 12 "dev": "vite dev", 13 + "dev:ingester": "tsx src/ingester/main.ts", 13 14 "build": "pnpm lex:build && vite build", 14 15 "preview": "vite preview", 15 16 "start": "node build", ··· 21 22 "format": "prettier --write .", 22 23 "lint": "prettier --check . && eslint .", 23 24 "lex:build": "lex build --lexicons ./lexicons --out ./src/lib/lexicons --clear", 24 - "db:push": "drizzle-kit push", 25 - "db:generate": "drizzle-kit generate", 26 - "db:migrate": "drizzle-kit migrate", 27 - "db:studio": "drizzle-kit studio" 25 + "db:push": "pnpm db:push:content && pnpm db:push:local", 26 + "db:push:content": "drizzle-kit push --config drizzle.content.config.ts", 27 + "db:push:local": "drizzle-kit push --config drizzle.local.config.ts", 28 + "db:generate": "pnpm db:generate:content && pnpm db:generate:local", 29 + "db:generate:content": "drizzle-kit generate --config drizzle.content.config.ts", 30 + "db:generate:local": "drizzle-kit generate --config drizzle.local.config.ts", 31 + "db:migrate": "pnpm db:migrate:content && pnpm db:migrate:local", 32 + "db:migrate:content": "drizzle-kit migrate --config drizzle.content.config.ts", 33 + "db:migrate:local": "drizzle-kit migrate --config drizzle.local.config.ts", 34 + "db:studio": "drizzle-kit studio", 35 + "db:studio:content": "drizzle-kit studio --config drizzle.content.config.ts", 36 + "db:studio:local": "drizzle-kit studio --config drizzle.local.config.ts" 28 37 }, 29 38 "devDependencies": { 30 39 "@atproto/lex": "^0.0.5", ··· 37 46 "@sveltejs/vite-plugin-svelte": "^6.2.1", 38 47 "@tailwindcss/vite": "^4.1.17", 39 48 "@types/node": "^22", 49 + "@types/ws": "^8.18.0", 40 50 "@vitest/browser-playwright": "^4.0.10", 41 51 "better-sqlite3": "^12.5.0", 42 52 "drizzle-kit": "^0.31.7", ··· 51 61 "svelte": "^5.43.8", 52 62 "svelte-check": "^4.3.4", 53 63 "tailwindcss": "^4.1.17", 64 + "tsx": "^4.21.0", 54 65 "typescript": "^5.9.3", 55 66 "typescript-eslint": "^8.47.0", 56 67 "vite": "^7.2.2", ··· 62 73 "@atproto/identity": "^0.4.10", 63 74 "@atproto/lex-cbor": "^0.0.2", 64 75 "@atproto/oauth-client-node": "^0.3.12", 65 - "iron-session": "^8.0.4" 76 + "iron-session": "^8.0.4", 77 + "ws": "^8.18.0" 66 78 } 67 79 }
+331 -41
pnpm-lock.yaml
··· 23 23 iron-session: 24 24 specifier: ^8.0.4 25 25 version: 8.0.4 26 + ws: 27 + specifier: ^8.18.0 28 + version: 8.18.3 26 29 devDependencies: 27 30 '@atproto/lex': 28 31 specifier: ^0.0.5 ··· 38 41 version: 0.15.15 39 42 '@sveltejs/adapter-auto': 40 43 specifier: ^7.0.0 41 - version: 7.0.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))) 44 + version: 7.0.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))) 42 45 '@sveltejs/adapter-node': 43 46 specifier: ^5.4.0 44 - version: 5.4.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))) 47 + version: 5.4.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))) 45 48 '@sveltejs/kit': 46 49 specifier: ^2.48.5 47 - version: 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 50 + version: 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 48 51 '@sveltejs/vite-plugin-svelte': 49 52 specifier: ^6.2.1 50 - version: 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 53 + version: 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 51 54 '@tailwindcss/vite': 52 55 specifier: ^4.1.17 53 - version: 4.1.17(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 56 + version: 4.1.17(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 54 57 '@types/node': 55 58 specifier: ^22 56 59 version: 22.19.1 60 + '@types/ws': 61 + specifier: ^8.18.0 62 + version: 8.18.1 57 63 '@vitest/browser-playwright': 58 64 specifier: ^4.0.10 59 - version: 4.0.14(playwright@1.57.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.14) 65 + version: 4.0.14(playwright@1.57.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.14) 60 66 better-sqlite3: 61 67 specifier: ^12.5.0 62 68 version: 12.5.0 ··· 96 102 tailwindcss: 97 103 specifier: ^4.1.17 98 104 version: 4.1.17 105 + tsx: 106 + specifier: ^4.21.0 107 + version: 4.21.0 99 108 typescript: 100 109 specifier: ^5.9.3 101 110 version: 5.9.3 ··· 104 113 version: 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) 105 114 vite: 106 115 specifier: ^7.2.2 107 - version: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 116 + version: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 108 117 vitest: 109 118 specifier: ^4.0.10 110 - version: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2) 119 + version: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 111 120 vitest-browser-svelte: 112 121 specifier: ^2.0.1 113 122 version: 2.0.1(svelte@5.45.2)(vitest@4.0.14) ··· 244 253 cpu: [ppc64] 245 254 os: [aix] 246 255 256 + '@esbuild/aix-ppc64@0.27.1': 257 + resolution: {integrity: sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==} 258 + engines: {node: '>=18'} 259 + cpu: [ppc64] 260 + os: [aix] 261 + 247 262 '@esbuild/android-arm64@0.18.20': 248 263 resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} 249 264 engines: {node: '>=12'} ··· 256 271 cpu: [arm64] 257 272 os: [android] 258 273 274 + '@esbuild/android-arm64@0.27.1': 275 + resolution: {integrity: sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==} 276 + engines: {node: '>=18'} 277 + cpu: [arm64] 278 + os: [android] 279 + 259 280 '@esbuild/android-arm@0.18.20': 260 281 resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} 261 282 engines: {node: '>=12'} ··· 268 289 cpu: [arm] 269 290 os: [android] 270 291 292 + '@esbuild/android-arm@0.27.1': 293 + resolution: {integrity: sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==} 294 + engines: {node: '>=18'} 295 + cpu: [arm] 296 + os: [android] 297 + 271 298 '@esbuild/android-x64@0.18.20': 272 299 resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} 273 300 engines: {node: '>=12'} ··· 280 307 cpu: [x64] 281 308 os: [android] 282 309 310 + '@esbuild/android-x64@0.27.1': 311 + resolution: {integrity: sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==} 312 + engines: {node: '>=18'} 313 + cpu: [x64] 314 + os: [android] 315 + 283 316 '@esbuild/darwin-arm64@0.18.20': 284 317 resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} 285 318 engines: {node: '>=12'} ··· 292 325 cpu: [arm64] 293 326 os: [darwin] 294 327 328 + '@esbuild/darwin-arm64@0.27.1': 329 + resolution: {integrity: sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==} 330 + engines: {node: '>=18'} 331 + cpu: [arm64] 332 + os: [darwin] 333 + 295 334 '@esbuild/darwin-x64@0.18.20': 296 335 resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} 297 336 engines: {node: '>=12'} ··· 304 343 cpu: [x64] 305 344 os: [darwin] 306 345 346 + '@esbuild/darwin-x64@0.27.1': 347 + resolution: {integrity: sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==} 348 + engines: {node: '>=18'} 349 + cpu: [x64] 350 + os: [darwin] 351 + 307 352 '@esbuild/freebsd-arm64@0.18.20': 308 353 resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} 309 354 engines: {node: '>=12'} ··· 316 361 cpu: [arm64] 317 362 os: [freebsd] 318 363 364 + '@esbuild/freebsd-arm64@0.27.1': 365 + resolution: {integrity: sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==} 366 + engines: {node: '>=18'} 367 + cpu: [arm64] 368 + os: [freebsd] 369 + 319 370 '@esbuild/freebsd-x64@0.18.20': 320 371 resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} 321 372 engines: {node: '>=12'} ··· 328 379 cpu: [x64] 329 380 os: [freebsd] 330 381 382 + '@esbuild/freebsd-x64@0.27.1': 383 + resolution: {integrity: sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==} 384 + engines: {node: '>=18'} 385 + cpu: [x64] 386 + os: [freebsd] 387 + 331 388 '@esbuild/linux-arm64@0.18.20': 332 389 resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} 333 390 engines: {node: '>=12'} ··· 340 397 cpu: [arm64] 341 398 os: [linux] 342 399 400 + '@esbuild/linux-arm64@0.27.1': 401 + resolution: {integrity: sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==} 402 + engines: {node: '>=18'} 403 + cpu: [arm64] 404 + os: [linux] 405 + 343 406 '@esbuild/linux-arm@0.18.20': 344 407 resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} 345 408 engines: {node: '>=12'} ··· 352 415 cpu: [arm] 353 416 os: [linux] 354 417 418 + '@esbuild/linux-arm@0.27.1': 419 + resolution: {integrity: sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==} 420 + engines: {node: '>=18'} 421 + cpu: [arm] 422 + os: [linux] 423 + 355 424 '@esbuild/linux-ia32@0.18.20': 356 425 resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} 357 426 engines: {node: '>=12'} ··· 360 429 361 430 '@esbuild/linux-ia32@0.25.12': 362 431 resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} 432 + engines: {node: '>=18'} 433 + cpu: [ia32] 434 + os: [linux] 435 + 436 + '@esbuild/linux-ia32@0.27.1': 437 + resolution: {integrity: sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==} 363 438 engines: {node: '>=18'} 364 439 cpu: [ia32] 365 440 os: [linux] ··· 376 451 cpu: [loong64] 377 452 os: [linux] 378 453 454 + '@esbuild/linux-loong64@0.27.1': 455 + resolution: {integrity: sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==} 456 + engines: {node: '>=18'} 457 + cpu: [loong64] 458 + os: [linux] 459 + 379 460 '@esbuild/linux-mips64el@0.18.20': 380 461 resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} 381 462 engines: {node: '>=12'} ··· 384 465 385 466 '@esbuild/linux-mips64el@0.25.12': 386 467 resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} 468 + engines: {node: '>=18'} 469 + cpu: [mips64el] 470 + os: [linux] 471 + 472 + '@esbuild/linux-mips64el@0.27.1': 473 + resolution: {integrity: sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==} 387 474 engines: {node: '>=18'} 388 475 cpu: [mips64el] 389 476 os: [linux] ··· 400 487 cpu: [ppc64] 401 488 os: [linux] 402 489 490 + '@esbuild/linux-ppc64@0.27.1': 491 + resolution: {integrity: sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==} 492 + engines: {node: '>=18'} 493 + cpu: [ppc64] 494 + os: [linux] 495 + 403 496 '@esbuild/linux-riscv64@0.18.20': 404 497 resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} 405 498 engines: {node: '>=12'} ··· 412 505 cpu: [riscv64] 413 506 os: [linux] 414 507 508 + '@esbuild/linux-riscv64@0.27.1': 509 + resolution: {integrity: sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==} 510 + engines: {node: '>=18'} 511 + cpu: [riscv64] 512 + os: [linux] 513 + 415 514 '@esbuild/linux-s390x@0.18.20': 416 515 resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} 417 516 engines: {node: '>=12'} ··· 424 523 cpu: [s390x] 425 524 os: [linux] 426 525 526 + '@esbuild/linux-s390x@0.27.1': 527 + resolution: {integrity: sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==} 528 + engines: {node: '>=18'} 529 + cpu: [s390x] 530 + os: [linux] 531 + 427 532 '@esbuild/linux-x64@0.18.20': 428 533 resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} 429 534 engines: {node: '>=12'} ··· 436 541 cpu: [x64] 437 542 os: [linux] 438 543 544 + '@esbuild/linux-x64@0.27.1': 545 + resolution: {integrity: sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==} 546 + engines: {node: '>=18'} 547 + cpu: [x64] 548 + os: [linux] 549 + 439 550 '@esbuild/netbsd-arm64@0.25.12': 440 551 resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} 552 + engines: {node: '>=18'} 553 + cpu: [arm64] 554 + os: [netbsd] 555 + 556 + '@esbuild/netbsd-arm64@0.27.1': 557 + resolution: {integrity: sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==} 441 558 engines: {node: '>=18'} 442 559 cpu: [arm64] 443 560 os: [netbsd] ··· 454 571 cpu: [x64] 455 572 os: [netbsd] 456 573 574 + '@esbuild/netbsd-x64@0.27.1': 575 + resolution: {integrity: sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==} 576 + engines: {node: '>=18'} 577 + cpu: [x64] 578 + os: [netbsd] 579 + 457 580 '@esbuild/openbsd-arm64@0.25.12': 458 581 resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} 582 + engines: {node: '>=18'} 583 + cpu: [arm64] 584 + os: [openbsd] 585 + 586 + '@esbuild/openbsd-arm64@0.27.1': 587 + resolution: {integrity: sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==} 459 588 engines: {node: '>=18'} 460 589 cpu: [arm64] 461 590 os: [openbsd] ··· 472 601 cpu: [x64] 473 602 os: [openbsd] 474 603 604 + '@esbuild/openbsd-x64@0.27.1': 605 + resolution: {integrity: sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==} 606 + engines: {node: '>=18'} 607 + cpu: [x64] 608 + os: [openbsd] 609 + 475 610 '@esbuild/openharmony-arm64@0.25.12': 476 611 resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} 612 + engines: {node: '>=18'} 613 + cpu: [arm64] 614 + os: [openharmony] 615 + 616 + '@esbuild/openharmony-arm64@0.27.1': 617 + resolution: {integrity: sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==} 477 618 engines: {node: '>=18'} 478 619 cpu: [arm64] 479 620 os: [openharmony] ··· 490 631 cpu: [x64] 491 632 os: [sunos] 492 633 634 + '@esbuild/sunos-x64@0.27.1': 635 + resolution: {integrity: sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==} 636 + engines: {node: '>=18'} 637 + cpu: [x64] 638 + os: [sunos] 639 + 493 640 '@esbuild/win32-arm64@0.18.20': 494 641 resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} 495 642 engines: {node: '>=12'} ··· 502 649 cpu: [arm64] 503 650 os: [win32] 504 651 652 + '@esbuild/win32-arm64@0.27.1': 653 + resolution: {integrity: sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==} 654 + engines: {node: '>=18'} 655 + cpu: [arm64] 656 + os: [win32] 657 + 505 658 '@esbuild/win32-ia32@0.18.20': 506 659 resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} 507 660 engines: {node: '>=12'} ··· 514 667 cpu: [ia32] 515 668 os: [win32] 516 669 670 + '@esbuild/win32-ia32@0.27.1': 671 + resolution: {integrity: sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==} 672 + engines: {node: '>=18'} 673 + cpu: [ia32] 674 + os: [win32] 675 + 517 676 '@esbuild/win32-x64@0.18.20': 518 677 resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} 519 678 engines: {node: '>=12'} ··· 522 681 523 682 '@esbuild/win32-x64@0.25.12': 524 683 resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} 684 + engines: {node: '>=18'} 685 + cpu: [x64] 686 + os: [win32] 687 + 688 + '@esbuild/win32-x64@0.27.1': 689 + resolution: {integrity: sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==} 525 690 engines: {node: '>=18'} 526 691 cpu: [x64] 527 692 os: [win32] ··· 1401 1566 1402 1567 esbuild@0.25.12: 1403 1568 resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} 1569 + engines: {node: '>=18'} 1570 + hasBin: true 1571 + 1572 + esbuild@0.27.1: 1573 + resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} 1404 1574 engines: {node: '>=18'} 1405 1575 hasBin: true 1406 1576 ··· 2204 2374 tslib@2.8.1: 2205 2375 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 2206 2376 2377 + tsx@4.21.0: 2378 + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} 2379 + engines: {node: '>=18.0.0'} 2380 + hasBin: true 2381 + 2207 2382 tunnel-agent@0.6.0: 2208 2383 resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} 2209 2384 ··· 2650 2825 '@esbuild/aix-ppc64@0.25.12': 2651 2826 optional: true 2652 2827 2828 + '@esbuild/aix-ppc64@0.27.1': 2829 + optional: true 2830 + 2653 2831 '@esbuild/android-arm64@0.18.20': 2654 2832 optional: true 2655 2833 2656 2834 '@esbuild/android-arm64@0.25.12': 2835 + optional: true 2836 + 2837 + '@esbuild/android-arm64@0.27.1': 2657 2838 optional: true 2658 2839 2659 2840 '@esbuild/android-arm@0.18.20': ··· 2662 2843 '@esbuild/android-arm@0.25.12': 2663 2844 optional: true 2664 2845 2846 + '@esbuild/android-arm@0.27.1': 2847 + optional: true 2848 + 2665 2849 '@esbuild/android-x64@0.18.20': 2666 2850 optional: true 2667 2851 2668 2852 '@esbuild/android-x64@0.25.12': 2669 2853 optional: true 2670 2854 2855 + '@esbuild/android-x64@0.27.1': 2856 + optional: true 2857 + 2671 2858 '@esbuild/darwin-arm64@0.18.20': 2672 2859 optional: true 2673 2860 2674 2861 '@esbuild/darwin-arm64@0.25.12': 2675 2862 optional: true 2676 2863 2864 + '@esbuild/darwin-arm64@0.27.1': 2865 + optional: true 2866 + 2677 2867 '@esbuild/darwin-x64@0.18.20': 2678 2868 optional: true 2679 2869 2680 2870 '@esbuild/darwin-x64@0.25.12': 2681 2871 optional: true 2682 2872 2873 + '@esbuild/darwin-x64@0.27.1': 2874 + optional: true 2875 + 2683 2876 '@esbuild/freebsd-arm64@0.18.20': 2684 2877 optional: true 2685 2878 2686 2879 '@esbuild/freebsd-arm64@0.25.12': 2880 + optional: true 2881 + 2882 + '@esbuild/freebsd-arm64@0.27.1': 2687 2883 optional: true 2688 2884 2689 2885 '@esbuild/freebsd-x64@0.18.20': ··· 2692 2888 '@esbuild/freebsd-x64@0.25.12': 2693 2889 optional: true 2694 2890 2891 + '@esbuild/freebsd-x64@0.27.1': 2892 + optional: true 2893 + 2695 2894 '@esbuild/linux-arm64@0.18.20': 2696 2895 optional: true 2697 2896 2698 2897 '@esbuild/linux-arm64@0.25.12': 2898 + optional: true 2899 + 2900 + '@esbuild/linux-arm64@0.27.1': 2699 2901 optional: true 2700 2902 2701 2903 '@esbuild/linux-arm@0.18.20': ··· 2704 2906 '@esbuild/linux-arm@0.25.12': 2705 2907 optional: true 2706 2908 2909 + '@esbuild/linux-arm@0.27.1': 2910 + optional: true 2911 + 2707 2912 '@esbuild/linux-ia32@0.18.20': 2708 2913 optional: true 2709 2914 2710 2915 '@esbuild/linux-ia32@0.25.12': 2711 2916 optional: true 2712 2917 2918 + '@esbuild/linux-ia32@0.27.1': 2919 + optional: true 2920 + 2713 2921 '@esbuild/linux-loong64@0.18.20': 2714 2922 optional: true 2715 2923 2716 2924 '@esbuild/linux-loong64@0.25.12': 2717 2925 optional: true 2718 2926 2927 + '@esbuild/linux-loong64@0.27.1': 2928 + optional: true 2929 + 2719 2930 '@esbuild/linux-mips64el@0.18.20': 2720 2931 optional: true 2721 2932 2722 2933 '@esbuild/linux-mips64el@0.25.12': 2723 2934 optional: true 2724 2935 2936 + '@esbuild/linux-mips64el@0.27.1': 2937 + optional: true 2938 + 2725 2939 '@esbuild/linux-ppc64@0.18.20': 2726 2940 optional: true 2727 2941 2728 2942 '@esbuild/linux-ppc64@0.25.12': 2943 + optional: true 2944 + 2945 + '@esbuild/linux-ppc64@0.27.1': 2729 2946 optional: true 2730 2947 2731 2948 '@esbuild/linux-riscv64@0.18.20': ··· 2734 2951 '@esbuild/linux-riscv64@0.25.12': 2735 2952 optional: true 2736 2953 2954 + '@esbuild/linux-riscv64@0.27.1': 2955 + optional: true 2956 + 2737 2957 '@esbuild/linux-s390x@0.18.20': 2738 2958 optional: true 2739 2959 2740 2960 '@esbuild/linux-s390x@0.25.12': 2741 2961 optional: true 2742 2962 2963 + '@esbuild/linux-s390x@0.27.1': 2964 + optional: true 2965 + 2743 2966 '@esbuild/linux-x64@0.18.20': 2744 2967 optional: true 2745 2968 2746 2969 '@esbuild/linux-x64@0.25.12': 2970 + optional: true 2971 + 2972 + '@esbuild/linux-x64@0.27.1': 2747 2973 optional: true 2748 2974 2749 2975 '@esbuild/netbsd-arm64@0.25.12': 2750 2976 optional: true 2751 2977 2978 + '@esbuild/netbsd-arm64@0.27.1': 2979 + optional: true 2980 + 2752 2981 '@esbuild/netbsd-x64@0.18.20': 2753 2982 optional: true 2754 2983 2755 2984 '@esbuild/netbsd-x64@0.25.12': 2756 2985 optional: true 2757 2986 2987 + '@esbuild/netbsd-x64@0.27.1': 2988 + optional: true 2989 + 2758 2990 '@esbuild/openbsd-arm64@0.25.12': 2991 + optional: true 2992 + 2993 + '@esbuild/openbsd-arm64@0.27.1': 2759 2994 optional: true 2760 2995 2761 2996 '@esbuild/openbsd-x64@0.18.20': ··· 2764 2999 '@esbuild/openbsd-x64@0.25.12': 2765 3000 optional: true 2766 3001 3002 + '@esbuild/openbsd-x64@0.27.1': 3003 + optional: true 3004 + 2767 3005 '@esbuild/openharmony-arm64@0.25.12': 2768 3006 optional: true 2769 3007 3008 + '@esbuild/openharmony-arm64@0.27.1': 3009 + optional: true 3010 + 2770 3011 '@esbuild/sunos-x64@0.18.20': 2771 3012 optional: true 2772 3013 2773 3014 '@esbuild/sunos-x64@0.25.12': 3015 + optional: true 3016 + 3017 + '@esbuild/sunos-x64@0.27.1': 2774 3018 optional: true 2775 3019 2776 3020 '@esbuild/win32-arm64@0.18.20': ··· 2779 3023 '@esbuild/win32-arm64@0.25.12': 2780 3024 optional: true 2781 3025 3026 + '@esbuild/win32-arm64@0.27.1': 3027 + optional: true 3028 + 2782 3029 '@esbuild/win32-ia32@0.18.20': 2783 3030 optional: true 2784 3031 2785 3032 '@esbuild/win32-ia32@0.25.12': 2786 3033 optional: true 2787 3034 3035 + '@esbuild/win32-ia32@0.27.1': 3036 + optional: true 3037 + 2788 3038 '@esbuild/win32-x64@0.18.20': 2789 3039 optional: true 2790 3040 2791 3041 '@esbuild/win32-x64@0.25.12': 3042 + optional: true 3043 + 3044 + '@esbuild/win32-x64@0.27.1': 2792 3045 optional: true 2793 3046 2794 3047 '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': ··· 3064 3317 dependencies: 3065 3318 acorn: 8.15.0 3066 3319 3067 - '@sveltejs/adapter-auto@7.0.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))': 3320 + '@sveltejs/adapter-auto@7.0.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))': 3068 3321 dependencies: 3069 - '@sveltejs/kit': 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3322 + '@sveltejs/kit': 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3070 3323 3071 - '@sveltejs/adapter-node@5.4.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))': 3324 + '@sveltejs/adapter-node@5.4.0(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))': 3072 3325 dependencies: 3073 3326 '@rollup/plugin-commonjs': 28.0.9(rollup@4.53.3) 3074 3327 '@rollup/plugin-json': 6.1.0(rollup@4.53.3) 3075 3328 '@rollup/plugin-node-resolve': 16.0.3(rollup@4.53.3) 3076 - '@sveltejs/kit': 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3329 + '@sveltejs/kit': 2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3077 3330 rollup: 4.53.3 3078 3331 3079 - '@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))': 3332 + '@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': 3080 3333 dependencies: 3081 3334 '@standard-schema/spec': 1.0.0 3082 3335 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) 3083 - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3336 + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3084 3337 '@types/cookie': 0.6.0 3085 3338 acorn: 8.15.0 3086 3339 cookie: 0.6.0 ··· 3093 3346 set-cookie-parser: 2.7.2 3094 3347 sirv: 3.0.2 3095 3348 svelte: 5.45.2 3096 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 3349 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 3097 3350 3098 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))': 3351 + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': 3099 3352 dependencies: 3100 - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3353 + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3101 3354 debug: 4.4.3 3102 3355 svelte: 5.45.2 3103 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 3356 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 3104 3357 transitivePeerDependencies: 3105 3358 - supports-color 3106 3359 3107 - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))': 3360 + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': 3108 3361 dependencies: 3109 - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3362 + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.2)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3110 3363 debug: 4.4.3 3111 3364 deepmerge: 4.3.1 3112 3365 magic-string: 0.30.21 3113 3366 svelte: 5.45.2 3114 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 3115 - vitefu: 1.1.1(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3367 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 3368 + vitefu: 1.1.1(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3116 3369 transitivePeerDependencies: 3117 3370 - supports-color 3118 3371 ··· 3177 3430 '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 3178 3431 '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 3179 3432 3180 - '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))': 3433 + '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': 3181 3434 dependencies: 3182 3435 '@tailwindcss/node': 4.1.17 3183 3436 '@tailwindcss/oxide': 4.1.17 3184 3437 tailwindcss: 4.1.17 3185 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 3438 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 3186 3439 3187 3440 '@ts-morph/common@0.28.1': 3188 3441 dependencies: ··· 3305 3558 '@typescript-eslint/types': 8.48.0 3306 3559 eslint-visitor-keys: 4.2.1 3307 3560 3308 - '@vitest/browser-playwright@4.0.14(playwright@1.57.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.14)': 3561 + '@vitest/browser-playwright@4.0.14(playwright@1.57.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.14)': 3309 3562 dependencies: 3310 - '@vitest/browser': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.14) 3311 - '@vitest/mocker': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3563 + '@vitest/browser': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.14) 3564 + '@vitest/mocker': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3312 3565 playwright: 1.57.0 3313 3566 tinyrainbow: 3.0.3 3314 - vitest: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2) 3567 + vitest: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 3315 3568 transitivePeerDependencies: 3316 3569 - bufferutil 3317 3570 - msw 3318 3571 - utf-8-validate 3319 3572 - vite 3320 3573 3321 - '@vitest/browser@4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.14)': 3574 + '@vitest/browser@4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.14)': 3322 3575 dependencies: 3323 - '@vitest/mocker': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 3576 + '@vitest/mocker': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 3324 3577 '@vitest/utils': 4.0.14 3325 3578 magic-string: 0.30.21 3326 3579 pixelmatch: 7.1.0 3327 3580 pngjs: 7.0.0 3328 3581 sirv: 3.0.2 3329 3582 tinyrainbow: 3.0.3 3330 - vitest: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2) 3583 + vitest: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 3331 3584 ws: 8.18.3 3332 3585 transitivePeerDependencies: 3333 3586 - bufferutil ··· 3344 3597 chai: 6.2.1 3345 3598 tinyrainbow: 3.0.3 3346 3599 3347 - '@vitest/mocker@4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))': 3600 + '@vitest/mocker@4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': 3348 3601 dependencies: 3349 3602 '@vitest/spy': 4.0.14 3350 3603 estree-walker: 3.0.3 3351 3604 magic-string: 0.30.21 3352 3605 optionalDependencies: 3353 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 3606 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 3354 3607 3355 3608 '@vitest/pretty-format@4.0.14': 3356 3609 dependencies: ··· 3608 3861 '@esbuild/win32-arm64': 0.25.12 3609 3862 '@esbuild/win32-ia32': 0.25.12 3610 3863 '@esbuild/win32-x64': 0.25.12 3864 + 3865 + esbuild@0.27.1: 3866 + optionalDependencies: 3867 + '@esbuild/aix-ppc64': 0.27.1 3868 + '@esbuild/android-arm': 0.27.1 3869 + '@esbuild/android-arm64': 0.27.1 3870 + '@esbuild/android-x64': 0.27.1 3871 + '@esbuild/darwin-arm64': 0.27.1 3872 + '@esbuild/darwin-x64': 0.27.1 3873 + '@esbuild/freebsd-arm64': 0.27.1 3874 + '@esbuild/freebsd-x64': 0.27.1 3875 + '@esbuild/linux-arm': 0.27.1 3876 + '@esbuild/linux-arm64': 0.27.1 3877 + '@esbuild/linux-ia32': 0.27.1 3878 + '@esbuild/linux-loong64': 0.27.1 3879 + '@esbuild/linux-mips64el': 0.27.1 3880 + '@esbuild/linux-ppc64': 0.27.1 3881 + '@esbuild/linux-riscv64': 0.27.1 3882 + '@esbuild/linux-s390x': 0.27.1 3883 + '@esbuild/linux-x64': 0.27.1 3884 + '@esbuild/netbsd-arm64': 0.27.1 3885 + '@esbuild/netbsd-x64': 0.27.1 3886 + '@esbuild/openbsd-arm64': 0.27.1 3887 + '@esbuild/openbsd-x64': 0.27.1 3888 + '@esbuild/openharmony-arm64': 0.27.1 3889 + '@esbuild/sunos-x64': 0.27.1 3890 + '@esbuild/win32-arm64': 0.27.1 3891 + '@esbuild/win32-ia32': 0.27.1 3892 + '@esbuild/win32-x64': 0.27.1 3611 3893 3612 3894 escalade@3.2.0: {} 3613 3895 ··· 4377 4659 4378 4660 tslib@2.8.1: {} 4379 4661 4662 + tsx@4.21.0: 4663 + dependencies: 4664 + esbuild: 0.27.1 4665 + get-tsconfig: 4.13.0 4666 + optionalDependencies: 4667 + fsevents: 2.3.3 4668 + 4380 4669 tunnel-agent@0.6.0: 4381 4670 dependencies: 4382 4671 safe-buffer: 5.2.1 ··· 4418 4707 4419 4708 varint@6.0.0: {} 4420 4709 4421 - vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2): 4710 + vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): 4422 4711 dependencies: 4423 4712 esbuild: 0.25.12 4424 4713 fdir: 6.5.0(picomatch@4.0.3) ··· 4431 4720 fsevents: 2.3.3 4432 4721 jiti: 2.6.1 4433 4722 lightningcss: 1.30.2 4723 + tsx: 4.21.0 4434 4724 4435 - vitefu@1.1.1(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)): 4725 + vitefu@1.1.1(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): 4436 4726 optionalDependencies: 4437 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 4727 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 4438 4728 4439 4729 vitest-browser-svelte@2.0.1(svelte@5.45.2)(vitest@4.0.14): 4440 4730 dependencies: 4441 4731 svelte: 5.45.2 4442 - vitest: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2) 4732 + vitest: 4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 4443 4733 4444 - vitest@4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2): 4734 + vitest@4.0.14(@types/node@22.19.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): 4445 4735 dependencies: 4446 4736 '@vitest/expect': 4.0.14 4447 - '@vitest/mocker': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)) 4737 + '@vitest/mocker': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) 4448 4738 '@vitest/pretty-format': 4.0.14 4449 4739 '@vitest/runner': 4.0.14 4450 4740 '@vitest/snapshot': 4.0.14 ··· 4461 4751 tinyexec: 0.3.2 4462 4752 tinyglobby: 0.2.15 4463 4753 tinyrainbow: 3.0.3 4464 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2) 4754 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) 4465 4755 why-is-node-running: 2.3.0 4466 4756 optionalDependencies: 4467 4757 '@types/node': 22.19.1 4468 - '@vitest/browser-playwright': 4.0.14(playwright@1.57.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.14) 4758 + '@vitest/browser-playwright': 4.0.14(playwright@1.57.0)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.14) 4469 4759 transitivePeerDependencies: 4470 4760 - jiti 4471 4761 - less
+3
src/hooks.server.ts
··· 1 1 import type { Handle } from '@sveltejs/kit'; 2 2 import { getCurrentDid } from '$lib/server/auth'; 3 3 4 + // Ingester runs as a separate process on the LiteFS primary machine 5 + // See src/ingester/main.ts and fly.ingester.toml 6 + 4 7 export const handle: Handle = async ({ event, resolve }) => { 5 8 // Load the current user's DID from session cookie 6 9 const did = await getCurrentDid(event.cookies);
+210
src/ingester/handler.ts
··· 1 + /** 2 + * Event handler for processing Jetstream events 3 + */ 4 + 5 + import { eq } from 'drizzle-orm'; 6 + import type { JetstreamEvent } from './jetstream'; 7 + import { contentDb, type ContentDatabase } from '$lib/server/db'; 8 + import { posts, comments, accounts } from '$lib/server/db/schema'; 9 + import * as Post from '$lib/lexicons/one/papili/post.defs'; 10 + import * as Comment from '$lib/lexicons/one/papili/comment.defs'; 11 + 12 + const PAPILI_POST = 'one.papili.post'; 13 + const PAPILI_COMMENT = 'one.papili.comment'; 14 + 15 + export class EventHandler { 16 + private db: ContentDatabase; 17 + 18 + constructor(database?: ContentDatabase) { 19 + this.db = database ?? contentDb; 20 + } 21 + 22 + async handle(event: JetstreamEvent): Promise<void> { 23 + switch (event.kind) { 24 + case 'commit': 25 + await this.handleCommit(event); 26 + break; 27 + case 'account': 28 + await this.handleAccount(event); 29 + break; 30 + case 'identity': 31 + await this.handleIdentity(event); 32 + break; 33 + } 34 + } 35 + 36 + private async handleCommit( 37 + event: JetstreamEvent & { kind: 'commit' } 38 + ): Promise<void> { 39 + const { commit, did } = event; 40 + const { collection, rkey, operation } = commit; 41 + 42 + console.log(`[handler] Commit: ${operation} ${collection} from ${did}`); 43 + 44 + if (collection === PAPILI_POST) { 45 + await this.handlePost(did, rkey, operation, commit); 46 + } else if (collection === PAPILI_COMMENT) { 47 + await this.handleComment(did, rkey, operation, commit); 48 + } 49 + } 50 + 51 + private async handlePost( 52 + did: string, 53 + rkey: string, 54 + operation: 'create' | 'update' | 'delete', 55 + commit: { operation: string; record?: unknown; cid?: string } 56 + ): Promise<void> { 57 + const uri = `at://${did}/${PAPILI_POST}/${rkey}`; 58 + const now = new Date().toISOString(); 59 + 60 + if (operation === 'delete') { 61 + console.log(`[handler] Deleting post: ${uri}`); 62 + await this.db.delete(posts).where(eq(posts.uri, uri)); 63 + return; 64 + } 65 + 66 + // Parse and validate record 67 + const parsed = Post.$safeParse(commit.record); 68 + if (!parsed.success) { 69 + console.warn(`[handler] Invalid post record: ${uri}`); 70 + return; 71 + } 72 + const record = parsed.value; 73 + 74 + console.log(`[handler] ${operation === 'create' ? 'Creating' : 'Updating'} post: ${uri}`); 75 + 76 + await this.db 77 + .insert(posts) 78 + .values({ 79 + uri, 80 + cid: commit.cid!, 81 + authorDid: did, 82 + rkey, 83 + url: record.url ?? null, 84 + title: record.title, 85 + text: record.text ?? null, 86 + createdAt: record.createdAt, 87 + indexedAt: now, 88 + voteCount: 0 89 + }) 90 + .onConflictDoUpdate({ 91 + target: posts.uri, 92 + set: { 93 + cid: commit.cid!, 94 + url: record.url ?? null, 95 + title: record.title, 96 + text: record.text ?? null, 97 + indexedAt: now 98 + } 99 + }); 100 + } 101 + 102 + private async handleComment( 103 + did: string, 104 + rkey: string, 105 + operation: 'create' | 'update' | 'delete', 106 + commit: { operation: string; record?: unknown; cid?: string } 107 + ): Promise<void> { 108 + const uri = `at://${did}/${PAPILI_COMMENT}/${rkey}`; 109 + const now = new Date().toISOString(); 110 + 111 + if (operation === 'delete') { 112 + console.log(`[handler] Deleting comment: ${uri}`); 113 + await this.db.delete(comments).where(eq(comments.uri, uri)); 114 + return; 115 + } 116 + 117 + // Parse and validate record 118 + const parsed = Comment.$safeParse(commit.record); 119 + if (!parsed.success) { 120 + console.warn(`[handler] Invalid comment record: ${uri}`); 121 + return; 122 + } 123 + const record = parsed.value; 124 + 125 + console.log(`[handler] ${operation === 'create' ? 'Creating' : 'Updating'} comment: ${uri}`); 126 + 127 + await this.db 128 + .insert(comments) 129 + .values({ 130 + uri, 131 + cid: commit.cid!, 132 + authorDid: did, 133 + rkey, 134 + postUri: record.post.uri, 135 + postCid: record.post.cid, 136 + parentUri: record.parent?.uri ?? null, 137 + parentCid: record.parent?.cid ?? null, 138 + text: record.text, 139 + createdAt: record.createdAt, 140 + indexedAt: now, 141 + voteCount: 0 142 + }) 143 + .onConflictDoUpdate({ 144 + target: comments.uri, 145 + set: { 146 + cid: commit.cid!, 147 + text: record.text, 148 + indexedAt: now 149 + } 150 + }); 151 + } 152 + 153 + private async handleAccount( 154 + event: JetstreamEvent & { kind: 'account' } 155 + ): Promise<void> { 156 + const { account, did } = event; 157 + const now = new Date().toISOString(); 158 + 159 + // Only update if this event is newer (higher seq) 160 + console.log(`[handler] Account event for ${did}: active=${account.active}, status=${account.status ?? 'none'}`); 161 + 162 + await this.db 163 + .insert(accounts) 164 + .values({ 165 + did, 166 + handle: null, 167 + active: account.active ? 1 : 0, 168 + status: account.status ?? null, 169 + seq: account.seq, 170 + updatedAt: now 171 + }) 172 + .onConflictDoUpdate({ 173 + target: accounts.did, 174 + set: { 175 + active: account.active ? 1 : 0, 176 + status: account.status ?? null, 177 + seq: account.seq, 178 + updatedAt: now 179 + } 180 + }); 181 + } 182 + 183 + private async handleIdentity( 184 + event: JetstreamEvent & { kind: 'identity' } 185 + ): Promise<void> { 186 + const { identity, did } = event; 187 + const now = new Date().toISOString(); 188 + 189 + console.log(`[handler] Identity event for ${did}: handle=${identity.handle}`); 190 + 191 + await this.db 192 + .insert(accounts) 193 + .values({ 194 + did, 195 + handle: identity.handle, 196 + active: 1, 197 + status: null, 198 + seq: identity.seq, 199 + updatedAt: now 200 + }) 201 + .onConflictDoUpdate({ 202 + target: accounts.did, 203 + set: { 204 + handle: identity.handle, 205 + seq: identity.seq, 206 + updatedAt: now 207 + } 208 + }); 209 + } 210 + }
+114
src/ingester/index.ts
··· 1 + /** 2 + * Ingester module - runs as background task in SvelteKit 3 + */ 4 + 5 + import { eq } from 'drizzle-orm'; 6 + import { Jetstream, type JetstreamEvent } from './jetstream'; 7 + import { EventHandler } from './handler'; 8 + import { contentDb } from '$lib/server/db'; 9 + import { ingestionCursor } from '$lib/server/db/schema'; 10 + 11 + // Collections we want to subscribe to 12 + const WANTED_COLLECTIONS = ['one.papili.post', 'one.papili.comment']; 13 + 14 + let jetstream: Jetstream | null = null; 15 + let isStarting = false; 16 + 17 + /** 18 + * Start the ingester as a background task 19 + */ 20 + export async function startIngester(): Promise<void> { 21 + if (jetstream || isStarting) { 22 + console.log('[ingester] Already running or starting'); 23 + return; 24 + } 25 + 26 + isStarting = true; 27 + console.log('[ingester] Starting as background task'); 28 + 29 + // Load cursor from database 30 + let cursor: number | undefined; 31 + try { 32 + const result = await contentDb.select().from(ingestionCursor).where(eq(ingestionCursor.id, 1)); 33 + if (result.length > 0) { 34 + cursor = result[0].cursorUs; 35 + console.log(`[ingester] Resuming from cursor: ${cursor}`); 36 + } else { 37 + console.log('[ingester] No cursor found, starting from now'); 38 + } 39 + } catch (err) { 40 + console.log('[ingester] Could not load cursor, starting fresh:', err); 41 + } 42 + 43 + // Create event handler (uses shared db) 44 + const handler = new EventHandler(); 45 + 46 + // Save cursor to database 47 + async function setCursor(cursorUs: number): Promise<void> { 48 + const now = new Date().toISOString(); 49 + await contentDb 50 + .insert(ingestionCursor) 51 + .values({ id: 1, cursorUs, updatedAt: now }) 52 + .onConflictDoUpdate({ 53 + target: ingestionCursor.id, 54 + set: { cursorUs, updatedAt: now } 55 + }); 56 + } 57 + 58 + // Create Jetstream client 59 + jetstream = new Jetstream({ 60 + wantedCollections: WANTED_COLLECTIONS, 61 + cursor, 62 + setCursor, 63 + onEvent: async (event: JetstreamEvent) => { 64 + try { 65 + await handler.handle(event); 66 + } catch (err) { 67 + console.error('[ingester] Error handling event:', err); 68 + } 69 + }, 70 + onError: (error: Error) => { 71 + console.error('[ingester] Jetstream error:', error); 72 + } 73 + }); 74 + 75 + jetstream.start(); 76 + isStarting = false; 77 + console.log('[ingester] Background task started'); 78 + } 79 + 80 + /** 81 + * Stop the ingester gracefully 82 + */ 83 + export async function stopIngester(): Promise<void> { 84 + if (!jetstream) { 85 + return; 86 + } 87 + 88 + console.log('[ingester] Stopping...'); 89 + 90 + // Save final cursor before shutdown 91 + const finalCursor = jetstream.getCursor(); 92 + if (finalCursor !== undefined) { 93 + console.log(`[ingester] Saving final cursor: ${finalCursor}`); 94 + const now = new Date().toISOString(); 95 + await contentDb 96 + .insert(ingestionCursor) 97 + .values({ id: 1, cursorUs: finalCursor, updatedAt: now }) 98 + .onConflictDoUpdate({ 99 + target: ingestionCursor.id, 100 + set: { cursorUs: finalCursor, updatedAt: now } 101 + }); 102 + } 103 + 104 + jetstream.destroy(); 105 + jetstream = null; 106 + console.log('[ingester] Stopped'); 107 + } 108 + 109 + /** 110 + * Check if ingester is connected 111 + */ 112 + export function isIngesterConnected(): boolean { 113 + return jetstream?.isConnected() ?? false; 114 + }
+169
src/ingester/jetstream.ts
··· 1 + /** 2 + * Jetstream WebSocket client for ATProto event ingestion 3 + * Based on sidetrail's implementation pattern 4 + */ 5 + 6 + import WebSocket from 'ws'; 7 + 8 + // Event types from Jetstream 9 + export type JetstreamEvent = { 10 + did: string; 11 + time_us: number; 12 + } & (CommitEvent | AccountEvent | IdentityEvent); 13 + 14 + type CommitEvent = { 15 + kind: 'commit'; 16 + commit: 17 + | { 18 + operation: 'create' | 'update'; 19 + record: unknown; 20 + rev: string; 21 + collection: string; 22 + rkey: string; 23 + cid: string; 24 + } 25 + | { 26 + operation: 'delete'; 27 + rev: string; 28 + collection: string; 29 + rkey: string; 30 + }; 31 + }; 32 + 33 + type IdentityEvent = { 34 + kind: 'identity'; 35 + identity: { 36 + did: string; 37 + handle: string; 38 + seq: number; 39 + time: string; 40 + }; 41 + }; 42 + 43 + type AccountEvent = { 44 + kind: 'account'; 45 + account: { 46 + active: boolean; 47 + did: string; 48 + seq: number; 49 + time: string; 50 + status?: 'takendown' | 'suspended' | 'deleted' | 'deactivated'; 51 + }; 52 + }; 53 + 54 + export interface JetstreamConfig { 55 + instanceUrl?: string; 56 + wantedCollections: string[]; 57 + cursor?: number; 58 + setCursor?: (cursor: number) => Promise<void>; 59 + onEvent: (event: JetstreamEvent) => Promise<void>; 60 + onError?: (error: Error) => void; 61 + } 62 + 63 + const DEFAULT_JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe'; 64 + const CURSOR_WRITE_INTERVAL_MS = 30_000; // Write cursor every 30 seconds 65 + const RECONNECT_DELAY_MS = 5_000; // Reconnect after 5 seconds 66 + 67 + export class Jetstream { 68 + private ws?: WebSocket; 69 + private config: JetstreamConfig; 70 + private isStarted = false; 71 + private isDestroyed = false; 72 + private cursor?: number; 73 + private lastCursorWrite = 0; 74 + 75 + constructor(config: JetstreamConfig) { 76 + this.config = config; 77 + this.cursor = config.cursor; 78 + } 79 + 80 + private buildUrl(): string { 81 + const baseUrl = this.config.instanceUrl || DEFAULT_JETSTREAM_URL; 82 + const params = new URLSearchParams(); 83 + 84 + for (const collection of this.config.wantedCollections) { 85 + params.append('wantedCollections', collection); 86 + } 87 + 88 + if (this.cursor !== undefined) { 89 + params.append('cursor', this.cursor.toString()); 90 + } 91 + 92 + return `${baseUrl}?${params.toString()}`; 93 + } 94 + 95 + start(): void { 96 + if (this.isStarted || this.isDestroyed) { 97 + return; 98 + } 99 + 100 + this.isStarted = true; 101 + const url = this.buildUrl(); 102 + console.log(`[jetstream] Connecting to: ${url}`); 103 + 104 + this.ws = new WebSocket(url); 105 + 106 + this.ws.on('open', () => { 107 + console.log('[jetstream] Connection opened'); 108 + }); 109 + 110 + this.ws.on('message', async (data) => { 111 + try { 112 + const event: JetstreamEvent = JSON.parse(data.toString()); 113 + 114 + // Update cursor with throttling 115 + if (event.time_us !== undefined && this.config.setCursor) { 116 + this.cursor = event.time_us; 117 + const now = Date.now(); 118 + if (now - this.lastCursorWrite >= CURSOR_WRITE_INTERVAL_MS) { 119 + this.lastCursorWrite = now; 120 + await this.config.setCursor(event.time_us); 121 + console.log(`[jetstream] Cursor saved: ${event.time_us}`); 122 + } 123 + } 124 + 125 + // Process the event 126 + await this.config.onEvent(event); 127 + } catch (err) { 128 + console.error('[jetstream] Error processing message:', err); 129 + if (this.config.onError && err instanceof Error) { 130 + this.config.onError(err); 131 + } 132 + } 133 + }); 134 + 135 + this.ws.on('error', (err) => { 136 + console.error('[jetstream] WebSocket error:', err); 137 + if (this.config.onError) { 138 + this.config.onError(err); 139 + } 140 + }); 141 + 142 + this.ws.on('close', (code, reason) => { 143 + console.log(`[jetstream] Connection closed: ${code} ${reason?.toString() || ''}`); 144 + this.isStarted = false; 145 + 146 + if (!this.isDestroyed) { 147 + console.log(`[jetstream] Reconnecting in ${RECONNECT_DELAY_MS / 1000}s...`); 148 + setTimeout(() => this.start(), RECONNECT_DELAY_MS); 149 + } 150 + }); 151 + } 152 + 153 + destroy(): void { 154 + console.log('[jetstream] Destroying connection'); 155 + this.isDestroyed = true; 156 + if (this.ws) { 157 + this.ws.close(); 158 + this.ws = undefined; 159 + } 160 + } 161 + 162 + isConnected(): boolean { 163 + return this.ws?.readyState === WebSocket.OPEN; 164 + } 165 + 166 + getCursor(): number | undefined { 167 + return this.cursor; 168 + } 169 + }
+118
src/ingester/main.ts
··· 1 + /** 2 + * Standalone ingester entry point 3 + * 4 + * This runs as a separate process on the LiteFS primary machine. 5 + * It connects directly to SQLite without going through SvelteKit. 6 + */ 7 + 8 + import { createClient } from '@libsql/client'; 9 + import { drizzle } from 'drizzle-orm/libsql'; 10 + import { eq } from 'drizzle-orm'; 11 + import * as contentSchema from '../lib/server/db/content-schema'; 12 + import { Jetstream, type JetstreamEvent } from './jetstream'; 13 + import { EventHandler } from './handler'; 14 + 15 + // Database path - on Fly.io this will be the LiteFS mount 16 + const DB_PATH = process.env.CONTENT_DB_PATH || './data/content.db'; 17 + 18 + // Collections we want to subscribe to 19 + const WANTED_COLLECTIONS = ['one.papili.post', 'one.papili.comment']; 20 + 21 + // Create database connection 22 + const client = createClient({ 23 + url: `file:${DB_PATH}` 24 + }); 25 + const db = drizzle(client, { schema: contentSchema }); 26 + 27 + // Graceful shutdown 28 + let jetstream: Jetstream | null = null; 29 + 30 + async function shutdown() { 31 + console.log('[ingester] Shutting down...'); 32 + 33 + if (jetstream) { 34 + // Save final cursor before shutdown 35 + const finalCursor = jetstream.getCursor(); 36 + if (finalCursor !== undefined) { 37 + console.log(`[ingester] Saving final cursor: ${finalCursor}`); 38 + const now = new Date().toISOString(); 39 + await db 40 + .insert(contentSchema.ingestionCursor) 41 + .values({ id: 1, cursorUs: finalCursor, updatedAt: now }) 42 + .onConflictDoUpdate({ 43 + target: contentSchema.ingestionCursor.id, 44 + set: { cursorUs: finalCursor, updatedAt: now } 45 + }); 46 + } 47 + 48 + jetstream.destroy(); 49 + } 50 + 51 + console.log('[ingester] Shutdown complete'); 52 + process.exit(0); 53 + } 54 + 55 + process.on('SIGINT', shutdown); 56 + process.on('SIGTERM', shutdown); 57 + 58 + async function main() { 59 + console.log('[ingester] Starting standalone ingester'); 60 + console.log(`[ingester] Database path: ${DB_PATH}`); 61 + 62 + // Load cursor from database 63 + let cursor: number | undefined; 64 + try { 65 + const result = await db 66 + .select() 67 + .from(contentSchema.ingestionCursor) 68 + .where(eq(contentSchema.ingestionCursor.id, 1)); 69 + if (result.length > 0) { 70 + cursor = result[0].cursorUs; 71 + console.log(`[ingester] Resuming from cursor: ${cursor}`); 72 + } else { 73 + console.log('[ingester] No cursor found, starting from now'); 74 + } 75 + } catch (err) { 76 + console.log('[ingester] Could not load cursor, starting fresh:', err); 77 + } 78 + 79 + // Create event handler with our db connection 80 + const handler = new EventHandler(db); 81 + 82 + // Save cursor to database 83 + async function setCursor(cursorUs: number): Promise<void> { 84 + const now = new Date().toISOString(); 85 + await db 86 + .insert(contentSchema.ingestionCursor) 87 + .values({ id: 1, cursorUs, updatedAt: now }) 88 + .onConflictDoUpdate({ 89 + target: contentSchema.ingestionCursor.id, 90 + set: { cursorUs, updatedAt: now } 91 + }); 92 + } 93 + 94 + // Create Jetstream client 95 + jetstream = new Jetstream({ 96 + wantedCollections: WANTED_COLLECTIONS, 97 + cursor, 98 + setCursor, 99 + onEvent: async (event: JetstreamEvent) => { 100 + try { 101 + await handler.handle(event); 102 + } catch (err) { 103 + console.error('[ingester] Error handling event:', err); 104 + } 105 + }, 106 + onError: (error: Error) => { 107 + console.error('[ingester] Jetstream error:', error); 108 + } 109 + }); 110 + 111 + jetstream.start(); 112 + console.log('[ingester] Jetstream client started'); 113 + } 114 + 115 + main().catch((err) => { 116 + console.error('[ingester] Fatal error:', err); 117 + process.exit(1); 118 + });
+107 -16
src/lib/components/PostList.svelte
··· 1 1 <script lang="ts"> 2 + import { invalidateAll } from '$app/navigation'; 2 3 import Avatar from './Avatar.svelte'; 3 4 import VoteButton from './VoteButton.svelte'; 4 5 import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 6 + import { pendingPosts } from '$lib/stores/pending'; 5 7 import type { AuthorProfile } from '$lib/types'; 6 8 7 9 interface Post { 8 10 uri: string; 9 11 rkey: string; 10 - url: string; 12 + url?: string | null; 11 13 title: string; 14 + text?: string | null; 12 15 createdAt: string; 13 16 author: AuthorProfile; 14 17 commentCount?: number; ··· 20 23 posts: Post[]; 21 24 emptyMessage?: string; 22 25 canVote?: boolean; 26 + currentUserDid?: string; 27 + currentUserHandle?: string; 23 28 } 24 29 25 - let { posts, emptyMessage = 'No posts yet.', canVote = false }: Props = $props(); 30 + let { posts, emptyMessage = 'No posts yet.', canVote = false, currentUserDid, currentUserHandle }: Props = $props(); 31 + 32 + // Get pending posts using $ auto-subscription, filtering out duplicates 33 + let realRkeys = $derived(new Set(posts.map((p) => p.rkey))); 34 + let pending = $derived($pendingPosts.filter((p) => !realRkeys.has(p.rkey))); 35 + 36 + // Reconcile pending posts when real posts change - remove any that now exist in DB 37 + $effect(() => { 38 + pendingPosts.reconcile([...realRkeys]); 39 + }); 40 + 41 + // Poll for updates while there are pending posts 42 + $effect(() => { 43 + if (pending.length === 0) return; 44 + 45 + const interval = setInterval(() => { 46 + invalidateAll(); 47 + }, 2000); 48 + 49 + return () => clearInterval(interval); 50 + }); 26 51 </script> 27 52 28 - {#if posts.length === 0} 53 + {#if posts.length === 0 && pending.length === 0} 29 54 <div class="py-12 text-center text-gray-500 dark:text-gray-400"> 30 55 <p>{emptyMessage}</p> 31 56 <p class="mt-2"> ··· 34 59 </div> 35 60 {:else} 36 61 <ol class="space-y-2"> 62 + <!-- Pending posts (optimistic UI) --> 63 + {#each pending as post (post.rkey)} 64 + <li class="flex gap-2 text-sm opacity-70"> 65 + <span class="w-6 text-right text-gray-400 dark:text-gray-500 select-none"> 66 + <svg class="w-4 h-4 animate-spin inline" fill="none" viewBox="0 0 24 24"> 67 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 68 + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 69 + </svg> 70 + </span> 71 + <div class="flex-1 min-w-0"> 72 + <div> 73 + {#if post.url} 74 + <span class="text-gray-900 dark:text-gray-100"> 75 + {post.title} 76 + </span> 77 + <span class="text-xs text-gray-400 dark:text-gray-500 ml-1"> 78 + ({getDomain(post.url)}) 79 + </span> 80 + {:else} 81 + <span class="text-gray-900 dark:text-gray-100"> 82 + {post.title} 83 + </span> 84 + {/if} 85 + </div> 86 + {#if post.text} 87 + <p class="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{post.text}</p> 88 + {/if} 89 + <div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> 90 + by 91 + {#if currentUserHandle} 92 + <span class="text-violet-600 dark:text-violet-400">@{currentUserHandle}</span> 93 + {:else} 94 + <span class="text-violet-600 dark:text-violet-400">you</span> 95 + {/if} 96 + <span class="italic">posting...</span> 97 + </div> 98 + </div> 99 + <span class="flex-shrink-0 text-xs text-gray-400 dark:text-gray-500 self-start mt-0.5">0</span> 100 + </li> 101 + {/each} 102 + <!-- Real posts --> 37 103 {#each posts as post, i (post.uri)} 38 104 <li class="flex gap-2 text-sm"> 39 105 {#if canVote} ··· 48 114 {/if} 49 115 <div class="flex-1 min-w-0"> 50 116 <div> 51 - <a 52 - href={post.url} 53 - target="_blank" 54 - rel="noopener noreferrer" 55 - class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 56 - > 57 - {post.title} 58 - </a> 59 - <span class="text-xs text-gray-400 dark:text-gray-500 ml-1"> 60 - ({getDomain(post.url)}) 61 - </span> 117 + {#if post.url} 118 + <a 119 + href={post.url} 120 + target="_blank" 121 + rel="noopener noreferrer" 122 + class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 123 + > 124 + {post.title} 125 + </a> 126 + <a 127 + href="/from/{getDomain(post.url)}" 128 + class="text-xs text-gray-400 dark:text-gray-500 ml-1 hover:text-violet-600 dark:hover:text-violet-400" 129 + > 130 + ({getDomain(post.url)}) 131 + </a> 132 + {:else} 133 + <a 134 + href="/post/{post.rkey}" 135 + class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 136 + > 137 + {post.title} 138 + </a> 139 + {/if} 62 140 </div> 63 - <div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 flex-wrap"> 141 + {#if post.text} 142 + <p class="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{post.text}</p> 143 + {/if} 144 + <div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> 64 145 by 65 146 <Avatar 66 147 handle={post.author.handle} ··· 71 152 link 72 153 /> 73 154 {formatTimeAgo(post.createdAt)} 74 - | <a href="/post/{post.rkey}" class="hover:underline">{post.commentCount ?? 0} comment{post.commentCount === 1 ? '' : 's'}</a> 75 155 </div> 76 156 </div> 157 + <!-- Comment count with icon, right-aligned --> 158 + <a 159 + href="/post/{post.rkey}" 160 + class="flex-shrink-0 flex items-center gap-0.5 text-xs text-gray-400 dark:text-gray-500 hover:text-violet-600 dark:hover:text-violet-400 self-start mt-0.5" 161 + title="{post.commentCount ?? 0} comment{post.commentCount === 1 ? '' : 's'}" 162 + > 163 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 164 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> 165 + </svg> 166 + <span>{post.commentCount ?? 0}</span> 167 + </a> 77 168 </li> 78 169 {/each} 79 170 </ol>
+53
src/lib/server/db/content-schema.ts
··· 1 + /** 2 + * Content database schema - managed by ingester 3 + * Webapp has read-only access via LiteFS replica 4 + */ 5 + 6 + import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 7 + 8 + // Account lifecycle tracking (from Jetstream identity/account events) 9 + export const accounts = sqliteTable('accounts', { 10 + did: text('did').primaryKey(), 11 + handle: text('handle'), 12 + active: integer('active').notNull().default(1), 13 + status: text('status'), // null | 'deactivated' | 'suspended' | 'takendown' | 'deleted' 14 + seq: integer('seq').notNull().default(0), 15 + updatedAt: text('updated_at').notNull() 16 + }); 17 + 18 + // Posts - link submissions or text posts 19 + export const posts = sqliteTable('posts', { 20 + uri: text('uri').primaryKey(), 21 + cid: text('cid').notNull(), 22 + authorDid: text('author_did').notNull(), 23 + rkey: text('rkey').notNull(), 24 + url: text('url'), 25 + title: text('title').notNull(), 26 + text: text('text'), 27 + createdAt: text('created_at').notNull(), 28 + indexedAt: text('indexed_at').notNull(), 29 + voteCount: integer('vote_count').notNull().default(0) 30 + }); 31 + 32 + // Comments - threaded discussions 33 + export const comments = sqliteTable('comments', { 34 + uri: text('uri').primaryKey(), 35 + cid: text('cid').notNull(), 36 + authorDid: text('author_did').notNull(), 37 + rkey: text('rkey').notNull(), 38 + postUri: text('post_uri').notNull(), 39 + postCid: text('post_cid').notNull(), 40 + parentUri: text('parent_uri'), 41 + parentCid: text('parent_cid'), 42 + text: text('text').notNull(), 43 + createdAt: text('created_at').notNull(), 44 + indexedAt: text('indexed_at').notNull(), 45 + voteCount: integer('vote_count').notNull().default(0) 46 + }); 47 + 48 + // Jetstream cursor tracking (ingester only) 49 + export const ingestionCursor = sqliteTable('ingestion_cursor', { 50 + id: integer('id').primaryKey().default(1), 51 + cursorUs: integer('cursor_us').notNull(), 52 + updatedAt: text('updated_at').notNull() 53 + });
+31 -6
src/lib/server/db/index.ts
··· 1 + /** 2 + * Database connections 3 + * 4 + * In production: 5 + * - contentDb: LiteFS replica (read-only) at /litefs/content.db 6 + * - localDb: Local SQLite (read-write) at /data/local.db 7 + * 8 + * In development: 9 + * - Both use local files in ./data/ 10 + */ 11 + 1 12 import { createClient } from '@libsql/client'; 2 13 import { drizzle } from 'drizzle-orm/libsql'; 3 - import * as schema from './schema'; 14 + import * as contentSchema from './content-schema'; 15 + import * as localSchema from './local-schema'; 16 + 17 + // Content database paths 18 + const CONTENT_DB_PATH = process.env.CONTENT_DB_PATH || './data/content.db'; 19 + const LOCAL_DB_PATH = process.env.LOCAL_DB_PATH || './data/local.db'; 4 20 5 - const DATABASE_PATH = process.env.DATABASE_PATH || './data/papili.db'; 21 + // Content DB - posts, comments, accounts (read-only in webapp, write in ingester) 22 + const contentClient = createClient({ 23 + url: `file:${CONTENT_DB_PATH}` 24 + }); 25 + export const contentDb = drizzle(contentClient, { schema: contentSchema }); 6 26 7 - const client = createClient({ 8 - url: `file:${DATABASE_PATH}` 27 + // Local DB - auth, votes (webapp only) 28 + const localClient = createClient({ 29 + url: `file:${LOCAL_DB_PATH}` 9 30 }); 31 + export const localDb = drizzle(localClient, { schema: localSchema }); 10 32 11 - export const db = drizzle(client, { schema }); 33 + // Type exports 34 + export type ContentDatabase = typeof contentDb; 35 + export type LocalDatabase = typeof localDb; 12 36 13 - export type Database = typeof db; 37 + // Legacy export - auth code uses this type for localDb 38 + export type Database = typeof localDb;
+35
src/lib/server/db/local-schema.ts
··· 1 + /** 2 + * Local database schema - webapp only 3 + * Auth and votes, not replicated 4 + */ 5 + 6 + import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; 7 + 8 + // OAuth state tokens (short-lived, auto-cleanup after 15 min) 9 + export const authState = sqliteTable('auth_state', { 10 + key: text('key').primaryKey(), 11 + state: text('state').notNull(), 12 + createdAt: text('created_at').notNull() 13 + }); 14 + 15 + // OAuth sessions (long-lived) 16 + export const authSession = sqliteTable('auth_session', { 17 + key: text('key').primaryKey(), 18 + session: text('session').notNull(), 19 + createdAt: text('created_at').notNull(), 20 + updatedAt: text('updated_at').notNull() 21 + }); 22 + 23 + // Votes - private, stored locally only 24 + export const votes = sqliteTable( 25 + 'votes', 26 + { 27 + id: integer('id').primaryKey({ autoIncrement: true }), 28 + userDid: text('user_did').notNull(), 29 + targetUri: text('target_uri').notNull(), 30 + targetType: text('target_type').notNull(), 31 + value: integer('value').notNull(), 32 + createdAt: text('created_at').notNull() 33 + }, 34 + (table) => [uniqueIndex('votes_user_target_idx').on(table.userDid, table.targetUri)] 35 + );
+10 -87
src/lib/server/db/schema.ts
··· 1 - import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; 1 + /** 2 + * Combined schema exports for convenience 3 + * In production, these live in separate databases: 4 + * - Content DB (LiteFS replica): posts, comments, accounts, ingestionCursor 5 + * - Local DB: authState, authSession, votes 6 + */ 2 7 3 - // ============================================================================ 4 - // Auth tables 5 - // ============================================================================ 8 + // Content tables (ingester writes, webapp reads) 9 + export { accounts, posts, comments, ingestionCursor } from './content-schema'; 6 10 7 - // OAuth state tokens (short-lived, auto-cleanup after 15 min) 8 - export const authState = sqliteTable('auth_state', { 9 - key: text('key').primaryKey(), 10 - state: text('state').notNull(), // JSON serialized OAuth state 11 - createdAt: text('created_at').notNull() 12 - }); 13 - 14 - // OAuth sessions (long-lived) 15 - export const authSession = sqliteTable('auth_session', { 16 - key: text('key').primaryKey(), 17 - session: text('session').notNull(), // JSON serialized OAuth session 18 - createdAt: text('created_at').notNull(), 19 - updatedAt: text('updated_at').notNull() 20 - }); 21 - 22 - // Account lifecycle tracking 23 - // Status values follow AT Protocol account lifecycle: 24 - // - null: active account (default) 25 - // - 'deactivated': user temporarily paused account 26 - // - 'suspended': host temporarily paused account 27 - // - 'takendown': host took down account 28 - // - 'deleted': account deleted 29 - export const accounts = sqliteTable('accounts', { 30 - did: text('did').primaryKey(), 31 - handle: text('handle'), 32 - active: integer('active').notNull().default(1), // 1 = active, 0 = inactive 33 - status: text('status'), // null | 'deactivated' | 'suspended' | 'takendown' | 'deleted' 34 - updatedAt: text('updated_at').notNull() 35 - }); 36 - 37 - // ============================================================================ 38 - // Content tables 39 - // ============================================================================ 40 - 41 - // Posts - link submissions 42 - export const posts = sqliteTable('posts', { 43 - uri: text('uri').primaryKey(), // at://did/one.papili.post/rkey 44 - cid: text('cid').notNull(), // Content hash 45 - authorDid: text('author_did').notNull(), 46 - rkey: text('rkey').notNull(), 47 - url: text('url').notNull(), // The submitted URL 48 - title: text('title').notNull(), 49 - text: text('text'), // Optional description 50 - createdAt: text('created_at').notNull(), 51 - indexedAt: text('indexed_at').notNull(), // When we stored it 52 - voteCount: integer('vote_count').notNull().default(0) 53 - }); 54 - 55 - // Comments - threaded discussions 56 - export const comments = sqliteTable('comments', { 57 - uri: text('uri').primaryKey(), // at://did/one.papili.comment/rkey 58 - cid: text('cid').notNull(), // Content hash 59 - authorDid: text('author_did').notNull(), 60 - rkey: text('rkey').notNull(), 61 - // StrongRef to the root post 62 - postUri: text('post_uri').notNull(), 63 - postCid: text('post_cid').notNull(), 64 - // StrongRef to parent comment (null for top-level) 65 - parentUri: text('parent_uri'), 66 - parentCid: text('parent_cid'), 67 - text: text('text').notNull(), 68 - createdAt: text('created_at').notNull(), 69 - indexedAt: text('indexed_at').notNull(), 70 - voteCount: integer('vote_count').notNull().default(0) 71 - }); 72 - 73 - // ============================================================================ 74 - // Voting tables 75 - // ============================================================================ 76 - 77 - // Votes - private, stored locally only (not published to ATProto) 78 - export const votes = sqliteTable( 79 - 'votes', 80 - { 81 - id: integer('id').primaryKey({ autoIncrement: true }), 82 - userDid: text('user_did').notNull(), 83 - targetUri: text('target_uri').notNull(), // URI of post or comment 84 - targetType: text('target_type').notNull(), // 'post' or 'comment' 85 - value: integer('value').notNull(), // 1 (upvote) or -1 (downvote) 86 - createdAt: text('created_at').notNull() 87 - }, 88 - (table) => [uniqueIndex('votes_user_target_idx').on(table.userDid, table.targetUri)] 89 - ); 11 + // Local tables (webapp reads/writes) 12 + export { authState, authSession, votes } from './local-schema';
+2 -2
src/lib/server/lex-client.ts
··· 6 6 import { IdResolver } from '@atproto/identity'; 7 7 import { TokenRefreshError } from '@atproto/oauth-client-node'; 8 8 import { createOAuthClient, getSession } from './auth'; 9 - import { db } from './db'; 9 + import { localDb } from './db'; 10 10 11 11 const idResolver = new IdResolver(); 12 12 ··· 37 37 throw new AuthRequiredError(); 38 38 } 39 39 40 - const oauthClient = await createOAuthClient(db); 40 + const oauthClient = await createOAuthClient(localDb); 41 41 42 42 let oauthSession; 43 43 try {
+2 -2
src/lib/server/profiles.ts
··· 52 52 did: string 53 53 ): AuthorProfile { 54 54 return ( 55 - profiles.get(did) ?? { 55 + profiles.get(did) ?? ({ 56 56 did, 57 57 handle: did.slice(0, 20) + '...' 58 - } 58 + } as AuthorProfile) 59 59 ); 60 60 }
+43
src/lib/server/vote-counts.ts
··· 1 + import { localDb } from '$lib/server/db'; 2 + import { votes } from '$lib/server/db/schema'; 3 + import { eq, inArray, sql, and } from 'drizzle-orm'; 4 + 5 + /** 6 + * Get vote counts for a list of URIs from the local votes table 7 + */ 8 + export async function getVoteCounts(uris: string[]): Promise<Map<string, number>> { 9 + if (uris.length === 0) return new Map(); 10 + 11 + const counts = await localDb 12 + .select({ 13 + targetUri: votes.targetUri, 14 + count: sql<number>`SUM(${votes.value})` 15 + }) 16 + .from(votes) 17 + .where(inArray(votes.targetUri, uris)) 18 + .groupBy(votes.targetUri); 19 + 20 + const result = new Map<string, number>(); 21 + for (const row of counts) { 22 + result.set(row.targetUri, row.count); 23 + } 24 + return result; 25 + } 26 + 27 + /** 28 + * Get user's votes for a list of URIs 29 + */ 30 + export async function getUserVotes(userDid: string, uris: string[]): Promise<Map<string, number>> { 31 + if (uris.length === 0) return new Map(); 32 + 33 + const userVoteRows = await localDb 34 + .select({ targetUri: votes.targetUri, value: votes.value }) 35 + .from(votes) 36 + .where(and(eq(votes.userDid, userDid), inArray(votes.targetUri, uris))); 37 + 38 + const result = new Map<string, number>(); 39 + for (const vote of userVoteRows) { 40 + result.set(vote.targetUri, vote.value); 41 + } 42 + return result; 43 + }
+99
src/lib/stores/pending.ts
··· 1 + import { writable, derived } from 'svelte/store'; 2 + 3 + export interface PendingPost { 4 + rkey: string; 5 + uri: string; 6 + authorDid: string; 7 + url: string | null; 8 + title: string; 9 + text: string | null; 10 + createdAt: string; 11 + submittedAt: number; // timestamp for expiration 12 + } 13 + 14 + export interface PendingComment { 15 + rkey: string; 16 + uri: string; 17 + authorDid: string; 18 + postUri: string; 19 + postRkey: string; 20 + parentUri: string | null; 21 + text: string; 22 + createdAt: string; 23 + submittedAt: number; 24 + } 25 + 26 + // Pending posts store 27 + const pendingPostsStore = writable<PendingPost[]>([]); 28 + 29 + // Pending comments store 30 + const pendingCommentsStore = writable<PendingComment[]>([]); 31 + 32 + // Expiration time (5 minutes) - if ingester hasn't picked it up, something's wrong 33 + const EXPIRATION_MS = 5 * 60 * 1000; 34 + 35 + function cleanExpired<T extends { submittedAt: number }>(items: T[]): T[] { 36 + const now = Date.now(); 37 + return items.filter((item) => now - item.submittedAt < EXPIRATION_MS); 38 + } 39 + 40 + export const pendingPosts = { 41 + subscribe: pendingPostsStore.subscribe, 42 + 43 + add(post: Omit<PendingPost, 'submittedAt'>) { 44 + pendingPostsStore.update((posts) => { 45 + const cleaned = cleanExpired(posts); 46 + return [...cleaned, { ...post, submittedAt: Date.now() }]; 47 + }); 48 + }, 49 + 50 + remove(rkey: string) { 51 + pendingPostsStore.update((posts) => posts.filter((p) => p.rkey !== rkey)); 52 + }, 53 + 54 + // Remove pending posts that now exist in the real data 55 + reconcile(realRkeys: string[]) { 56 + pendingPostsStore.update((posts) => { 57 + const cleaned = cleanExpired(posts); 58 + return cleaned.filter((p) => !realRkeys.includes(p.rkey)); 59 + }); 60 + }, 61 + 62 + clear() { 63 + pendingPostsStore.set([]); 64 + } 65 + }; 66 + 67 + export const pendingComments = { 68 + subscribe: pendingCommentsStore.subscribe, 69 + 70 + add(comment: Omit<PendingComment, 'submittedAt'>) { 71 + pendingCommentsStore.update((comments) => { 72 + const cleaned = cleanExpired(comments); 73 + return [...cleaned, { ...comment, submittedAt: Date.now() }]; 74 + }); 75 + }, 76 + 77 + remove(rkey: string) { 78 + pendingCommentsStore.update((comments) => comments.filter((c) => c.rkey !== rkey)); 79 + }, 80 + 81 + // Remove pending comments that now exist in the real data 82 + reconcile(realRkeys: string[]) { 83 + pendingCommentsStore.update((comments) => { 84 + const cleaned = cleanExpired(comments); 85 + return cleaned.filter((c) => !realRkeys.includes(c.rkey)); 86 + }); 87 + }, 88 + 89 + // Get pending comments for a specific post 90 + forPost(postUri: string) { 91 + return derived(pendingCommentsStore, ($comments) => 92 + cleanExpired($comments).filter((c) => c.postUri === postUri) 93 + ); 94 + }, 95 + 96 + clear() { 97 + pendingCommentsStore.set([]); 98 + } 99 + };
+9
src/lib/utils/ranking.ts
··· 1 + /** 2 + * Calculate hot score using HN-style algorithm 3 + * score = votes / (age_hours + 2)^gravity 4 + */ 5 + export function calculateHotScore(voteCount: number, createdAt: string, gravity = 1.5): number { 6 + const ageMs = Date.now() - new Date(createdAt).getTime(); 7 + const ageHours = ageMs / (1000 * 60 * 60); 8 + return voteCount / Math.pow(ageHours + 2, gravity); 9 + }
+2 -1
src/routes/+layout.svelte
··· 27 27 <nav class="mx-auto flex max-w-4xl items-center gap-3 px-2 sm:px-4 py-2 text-sm"> 28 28 <Logo /> 29 29 <a href="/new" class="text-violet-200 hover:text-white">new</a> 30 + <a href="/comments" class="text-violet-200 hover:text-white">comments</a> 30 31 31 32 <div class="flex-1"></div> 32 33 ··· 78 79 class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" 79 80 onclick={closeMenu} 80 81 > 81 - Submit link 82 + Submit 82 83 </a> 83 84 {#if data.user} 84 85 <a
+26 -26
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 - import { db } from '$lib/server/db'; 3 - import { posts, comments, votes } from '$lib/server/db/schema'; 4 - import { desc, eq, count, and, inArray } from 'drizzle-orm'; 2 + import { contentDb } from '$lib/server/db'; 3 + import { posts, comments } from '$lib/server/db/schema'; 4 + import { desc, eq, count } from 'drizzle-orm'; 5 5 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 + import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 + import { calculateHotScore } from '$lib/utils/ranking'; 6 8 7 9 export const load: PageServerLoad = async ({ locals }) => { 8 - // Fetch recent posts with comment counts and vote counts 9 - const recentPosts = await db 10 + // Fetch recent posts with comment counts from content DB 11 + const recentPosts = await contentDb 10 12 .select({ 11 13 uri: posts.uri, 12 14 cid: posts.cid, ··· 17 19 text: posts.text, 18 20 createdAt: posts.createdAt, 19 21 indexedAt: posts.indexedAt, 20 - voteCount: posts.voteCount, 21 22 commentCount: count(comments.uri) 22 23 }) 23 24 .from(posts) 24 25 .leftJoin(comments, eq(comments.postUri, posts.uri)) 25 26 .groupBy(posts.uri) 26 27 .orderBy(desc(posts.createdAt)) 27 - .limit(50); 28 + .limit(100); 28 29 29 30 // Fetch author profiles for all posts 30 31 const authorDids = recentPosts.map((p) => p.authorDid); 31 32 const profiles = await fetchProfiles(authorDids); 32 33 33 - // Fetch user's votes for these posts (if logged in) 34 - const userVotes = new Map<string, number>(); 35 - if (locals.did && recentPosts.length > 0) { 36 - const postUris = recentPosts.map((p) => p.uri); 37 - const userVoteRows = await db 38 - .select({ targetUri: votes.targetUri, value: votes.value }) 39 - .from(votes) 40 - .where(and(eq(votes.userDid, locals.did), inArray(votes.targetUri, postUris))); 34 + // Get vote counts and user votes from local DB 35 + const postUris = recentPosts.map((p) => p.uri); 36 + const voteCounts = await getVoteCounts(postUris); 37 + const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 41 38 42 - for (const vote of userVoteRows) { 43 - userVotes.set(vote.targetUri, vote.value); 44 - } 45 - } 39 + // Combine posts with author profiles, vote counts, user votes, and hot score 40 + const postsWithData = recentPosts.map((post) => { 41 + const voteCount = voteCounts.get(post.uri) ?? 0; 42 + return { 43 + ...post, 44 + voteCount, 45 + author: getProfileOrFallback(profiles, post.authorDid), 46 + userVote: userVotes.get(post.uri) ?? 0, 47 + hotScore: calculateHotScore(voteCount, post.createdAt) 48 + }; 49 + }); 46 50 47 - // Combine posts with author profiles and user votes 48 - const postsWithData = recentPosts.map((post) => ({ 49 - ...post, 50 - author: getProfileOrFallback(profiles, post.authorDid), 51 - userVote: userVotes.get(post.uri) ?? 0 52 - })); 51 + // Sort by hot score (descending) and take top 50 52 + const hotPosts = postsWithData.sort((a, b) => b.hotScore - a.hotScore).slice(0, 50); 53 53 54 54 return { 55 - posts: postsWithData 55 + posts: hotPosts 56 56 }; 57 57 };
+6 -1
src/routes/+page.svelte
··· 8 8 <title>papili</title> 9 9 </svelte:head> 10 10 11 - <PostList posts={data.posts} canVote={!!data.user} /> 11 + <PostList 12 + posts={data.posts} 13 + canVote={!!data.user} 14 + currentUserDid={data.user?.did} 15 + currentUserHandle={data.user?.handle} 16 + />
+10 -22
src/routes/api/vote/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 - import { db } from '$lib/server/db'; 4 - import { votes, posts, comments } from '$lib/server/db/schema'; 5 - import { eq, and, sql } from 'drizzle-orm'; 3 + import { localDb } from '$lib/server/db'; 4 + import { votes } from '$lib/server/db/schema'; 5 + import { eq, and } from 'drizzle-orm'; 6 6 import { getCurrentDid } from '$lib/server/auth/session'; 7 7 8 8 export const POST: RequestHandler = async ({ request, cookies }) => { ··· 29 29 30 30 const now = new Date().toISOString(); 31 31 32 - // Get existing vote 33 - const [existingVote] = await db 32 + // Get existing vote from local DB 33 + const [existingVote] = await localDb 34 34 .select() 35 35 .from(votes) 36 36 .where(and(eq(votes.userDid, userDid), eq(votes.targetUri, targetUri))) ··· 44 44 return json({ success: true, newValue: value }); 45 45 } 46 46 47 - // Update or insert vote 47 + // Update or insert vote in local DB 48 48 if (value === 0) { 49 49 // Remove vote 50 - await db.delete(votes).where(and(eq(votes.userDid, userDid), eq(votes.targetUri, targetUri))); 50 + await localDb.delete(votes).where(and(eq(votes.userDid, userDid), eq(votes.targetUri, targetUri))); 51 51 } else if (existingVote) { 52 52 // Update existing vote 53 - await db 53 + await localDb 54 54 .update(votes) 55 55 .set({ value, createdAt: now }) 56 56 .where(and(eq(votes.userDid, userDid), eq(votes.targetUri, targetUri))); 57 57 } else { 58 58 // Insert new vote 59 - await db.insert(votes).values({ 59 + await localDb.insert(votes).values({ 60 60 userDid, 61 61 targetUri, 62 62 targetType, ··· 65 65 }); 66 66 } 67 67 68 - // Update vote count on target 69 - if (targetType === 'post') { 70 - await db 71 - .update(posts) 72 - .set({ voteCount: sql`${posts.voteCount} + ${delta}` }) 73 - .where(eq(posts.uri, targetUri)); 74 - } else { 75 - await db 76 - .update(comments) 77 - .set({ voteCount: sql`${comments.voteCount} + ${delta}` }) 78 - .where(eq(comments.uri, targetUri)); 79 - } 80 - 68 + // Vote counts are calculated dynamically from the votes table 81 69 return json({ success: true, newValue: value, delta }); 82 70 };
+45
src/routes/comments/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import { contentDb } from '$lib/server/db'; 3 + import { posts, comments } from '$lib/server/db/schema'; 4 + import { desc, eq } from 'drizzle-orm'; 5 + import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 + import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 + 8 + export const load: PageServerLoad = async ({ locals }) => { 9 + // Fetch recent comments with post info from content DB 10 + const recentComments = await contentDb 11 + .select({ 12 + uri: comments.uri, 13 + cid: comments.cid, 14 + authorDid: comments.authorDid, 15 + rkey: comments.rkey, 16 + postUri: comments.postUri, 17 + text: comments.text, 18 + createdAt: comments.createdAt, 19 + postRkey: posts.rkey, 20 + postTitle: posts.title 21 + }) 22 + .from(comments) 23 + .innerJoin(posts, eq(comments.postUri, posts.uri)) 24 + .orderBy(desc(comments.createdAt)) 25 + .limit(50); 26 + 27 + const authorDids = recentComments.map((c) => c.authorDid); 28 + const profiles = await fetchProfiles(authorDids); 29 + 30 + // Get vote counts and user votes from local DB 31 + const commentUris = recentComments.map((c) => c.uri); 32 + const voteCounts = await getVoteCounts(commentUris); 33 + const userVotes = locals.did ? await getUserVotes(locals.did, commentUris) : new Map(); 34 + 35 + const commentsWithData = recentComments.map((comment) => ({ 36 + ...comment, 37 + voteCount: voteCounts.get(comment.uri) ?? 0, 38 + author: getProfileOrFallback(profiles, comment.authorDid), 39 + userVote: userVotes.get(comment.uri) ?? 0 40 + })); 41 + 42 + return { 43 + comments: commentsWithData 44 + }; 45 + };
+87
src/routes/comments/+page.svelte
··· 1 + <script lang="ts"> 2 + import Avatar from '$lib/components/Avatar.svelte'; 3 + import VoteButton from '$lib/components/VoteButton.svelte'; 4 + import { formatTimeAgo } from '$lib/utils/formatting'; 5 + 6 + let { data } = $props(); 7 + let canVote = $derived(!!data.user); 8 + </script> 9 + 10 + <svelte:head> 11 + <title>Recent Comments - papili</title> 12 + </svelte:head> 13 + 14 + <div class="space-y-4"> 15 + <h1 class="text-lg font-medium text-gray-900 dark:text-gray-100">Recent Comments</h1> 16 + 17 + {#if data.comments.length === 0} 18 + <div class="py-12 text-center text-gray-500 dark:text-gray-400"> 19 + <p>No comments yet.</p> 20 + </div> 21 + {:else} 22 + <ol class="space-y-3"> 23 + {#each data.comments as comment (comment.uri)} 24 + <li class="text-sm border-b border-gray-100 dark:border-gray-800 pb-3 last:border-0"> 25 + <div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1"> 26 + {#if canVote} 27 + <div class="inline-vote"> 28 + <VoteButton 29 + targetUri={comment.uri} 30 + targetType="comment" 31 + voteCount={comment.voteCount} 32 + userVote={comment.userVote} 33 + /> 34 + </div> 35 + {:else} 36 + <span>{comment.voteCount} pt{comment.voteCount === 1 ? '' : 's'}</span> 37 + <span class="mx-0.5">|</span> 38 + {/if} 39 + <Avatar 40 + handle={comment.author.handle} 41 + avatar={comment.author.avatar} 42 + did={comment.author.did} 43 + size="xs" 44 + showHandle 45 + link 46 + /> 47 + <span>{formatTimeAgo(comment.createdAt)}</span> 48 + <span class="mx-0.5">|</span> 49 + <span>on</span> 50 + <a 51 + href="/post/{comment.postRkey}" 52 + class="text-violet-600 dark:text-violet-400 hover:underline truncate max-w-[200px] inline-block align-bottom" 53 + > 54 + {comment.postTitle} 55 + </a> 56 + </div> 57 + <div class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words"> 58 + {comment.text} 59 + </div> 60 + </li> 61 + {/each} 62 + </ol> 63 + {/if} 64 + </div> 65 + 66 + <style> 67 + .inline-vote { 68 + display: inline-flex; 69 + align-items: center; 70 + margin-right: 0.25rem; 71 + } 72 + 73 + .inline-vote :global(.vote-buttons) { 74 + flex-direction: row; 75 + gap: 0.125rem; 76 + } 77 + 78 + .inline-vote :global(.vote-icon) { 79 + width: 0.75rem; 80 + height: 0.75rem; 81 + } 82 + 83 + .inline-vote :global(.vote-count) { 84 + font-size: 0.625rem; 85 + min-width: 1rem; 86 + } 87 + </style>
+51
src/routes/from/[domain]/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import { contentDb } from '$lib/server/db'; 3 + import { posts, comments } from '$lib/server/db/schema'; 4 + import { desc, eq, count, like } from 'drizzle-orm'; 5 + import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 + import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 + 8 + export const load: PageServerLoad = async ({ locals, params }) => { 9 + const domain = params.domain; 10 + 11 + // Fetch posts from this domain with comment counts from content DB 12 + const domainPosts = await contentDb 13 + .select({ 14 + uri: posts.uri, 15 + cid: posts.cid, 16 + authorDid: posts.authorDid, 17 + rkey: posts.rkey, 18 + url: posts.url, 19 + title: posts.title, 20 + text: posts.text, 21 + createdAt: posts.createdAt, 22 + indexedAt: posts.indexedAt, 23 + commentCount: count(comments.uri) 24 + }) 25 + .from(posts) 26 + .leftJoin(comments, eq(comments.postUri, posts.uri)) 27 + .where(like(posts.url, `%://${domain}%`)) 28 + .groupBy(posts.uri) 29 + .orderBy(desc(posts.createdAt)) 30 + .limit(50); 31 + 32 + const authorDids = domainPosts.map((p) => p.authorDid); 33 + const profiles = await fetchProfiles(authorDids); 34 + 35 + // Get vote counts and user votes from local DB 36 + const postUris = domainPosts.map((p) => p.uri); 37 + const voteCounts = await getVoteCounts(postUris); 38 + const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 39 + 40 + const postsWithData = domainPosts.map((post) => ({ 41 + ...post, 42 + voteCount: voteCounts.get(post.uri) ?? 0, 43 + author: getProfileOrFallback(profiles, post.authorDid), 44 + userVote: userVotes.get(post.uri) ?? 0 45 + })); 46 + 47 + return { 48 + domain, 49 + posts: postsWithData 50 + }; 51 + };
+23
src/routes/from/[domain]/+page.svelte
··· 1 + <script lang="ts"> 2 + import PostList from '$lib/components/PostList.svelte'; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + <svelte:head> 8 + <title>Posts from {data.domain} - papili</title> 9 + </svelte:head> 10 + 11 + <div class="space-y-4"> 12 + <h1 class="text-lg font-medium text-gray-900 dark:text-gray-100"> 13 + Posts from <span class="text-violet-600 dark:text-violet-400">{data.domain}</span> 14 + </h1> 15 + 16 + <PostList 17 + posts={data.posts} 18 + canVote={!!data.user} 19 + currentUserDid={data.user?.did} 20 + currentUserHandle={data.user?.handle} 21 + emptyMessage="No posts from {data.domain} yet." 22 + /> 23 + </div>
+2 -2
src/routes/login/+page.server.ts
··· 1 1 import { fail, redirect, isRedirect } from '@sveltejs/kit'; 2 2 import type { Actions, PageServerLoad } from './$types'; 3 3 import { createOAuthClient } from '$lib/server/auth'; 4 - import { db } from '$lib/server/db'; 4 + import { localDb } from '$lib/server/db'; 5 5 6 6 export const load: PageServerLoad = async ({ url, locals }) => { 7 7 // If already logged in, redirect to home ··· 27 27 const normalizedHandle = handle.includes('.') ? handle : `${handle}.bsky.social`; 28 28 29 29 try { 30 - const client = await createOAuthClient(db); 30 + const client = await createOAuthClient(localDb); 31 31 32 32 // Get the return URL from query params, or default to home 33 33 const returnUrl = url.searchParams.get('returnUrl') || '/';
+2 -2
src/routes/logout/+server.ts
··· 1 1 import { redirect, type RequestHandler } from '@sveltejs/kit'; 2 2 import { createOAuthClient, getSession } from '$lib/server/auth'; 3 - import { db } from '$lib/server/db'; 3 + import { localDb } from '$lib/server/db'; 4 4 5 5 export const POST: RequestHandler = async ({ cookies }) => { 6 6 const session = await getSession(cookies); ··· 8 8 if (session.did) { 9 9 try { 10 10 // Sign out from ATProto 11 - const client = await createOAuthClient(db); 11 + const client = await createOAuthClient(localDb); 12 12 const oauthSession = await client.restore(session.did); 13 13 if (oauthSession) { 14 14 await oauthSession.signOut();
+11 -19
src/routes/new/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 - import { db } from '$lib/server/db'; 3 - import { posts, comments, votes } from '$lib/server/db/schema'; 4 - import { desc, eq, count, and, inArray } from 'drizzle-orm'; 2 + import { contentDb } from '$lib/server/db'; 3 + import { posts, comments } from '$lib/server/db/schema'; 4 + import { desc, eq, count } from 'drizzle-orm'; 5 5 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 + import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 6 7 7 8 export const load: PageServerLoad = async ({ locals }) => { 8 - // Fetch posts ordered by creation time (newest first) with comment counts and vote counts 9 - const recentPosts = await db 9 + // Fetch posts ordered by creation time (newest first) with comment counts 10 + const recentPosts = await contentDb 10 11 .select({ 11 12 uri: posts.uri, 12 13 cid: posts.cid, ··· 17 18 text: posts.text, 18 19 createdAt: posts.createdAt, 19 20 indexedAt: posts.indexedAt, 20 - voteCount: posts.voteCount, 21 21 commentCount: count(comments.uri) 22 22 }) 23 23 .from(posts) ··· 29 29 const authorDids = recentPosts.map((p) => p.authorDid); 30 30 const profiles = await fetchProfiles(authorDids); 31 31 32 - // Fetch user's votes for these posts (if logged in) 33 - const userVotes = new Map<string, number>(); 34 - if (locals.did && recentPosts.length > 0) { 35 - const postUris = recentPosts.map((p) => p.uri); 36 - const userVoteRows = await db 37 - .select({ targetUri: votes.targetUri, value: votes.value }) 38 - .from(votes) 39 - .where(and(eq(votes.userDid, locals.did), inArray(votes.targetUri, postUris))); 40 - 41 - for (const vote of userVoteRows) { 42 - userVotes.set(vote.targetUri, vote.value); 43 - } 44 - } 32 + // Get vote counts and user votes from local DB 33 + const postUris = recentPosts.map((p) => p.uri); 34 + const voteCounts = await getVoteCounts(postUris); 35 + const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 45 36 46 37 const postsWithData = recentPosts.map((post) => ({ 47 38 ...post, 39 + voteCount: voteCounts.get(post.uri) ?? 0, 48 40 author: getProfileOrFallback(profiles, post.authorDid), 49 41 userVote: userVotes.get(post.uri) ?? 0 50 42 }));
+6 -1
src/routes/new/+page.svelte
··· 8 8 <title>New - papili</title> 9 9 </svelte:head> 10 10 11 - <PostList posts={data.posts} canVote={!!data.user} /> 11 + <PostList 12 + posts={data.posts} 13 + canVote={!!data.user} 14 + currentUserDid={data.user?.did} 15 + currentUserHandle={data.user?.handle} 16 + />
+2 -2
src/routes/oauth/callback/+server.ts
··· 1 1 import { redirect, isRedirect, type RequestHandler } from '@sveltejs/kit'; 2 2 import { createOAuthClient, getSession } from '$lib/server/auth'; 3 - import { db } from '$lib/server/db'; 3 + import { localDb } from '$lib/server/db'; 4 4 5 5 export const GET: RequestHandler = async ({ url, cookies }) => { 6 6 const params = url.searchParams; ··· 13 13 } 14 14 15 15 try { 16 - const client = await createOAuthClient(db); 16 + const client = await createOAuthClient(localDb); 17 17 18 18 // Complete the OAuth flow 19 19 const { session: oauthSession, state } = await client.callback(params);
+5 -2
src/routes/page.svelte.spec.ts
··· 21 21 }); 22 22 23 23 it('should render posts list', async () => { 24 - // @ts-expect-error - vitest-browser-svelte types issue 24 + // @ts-expect-error - vitest-browser-svelte types don't match runtime API 25 25 render(Page, { 26 26 props: { 27 27 data: { ··· 39 39 createdAt: new Date().toISOString(), 40 40 indexedAt: new Date().toISOString(), 41 41 commentCount: 0, 42 + voteCount: 0, 43 + userVote: 0, 44 + hotScore: 0, 42 45 author: { 43 - did: 'did:plc:test', 46 + did: 'did:plc:test' as `did:${string}:${string}`, 44 47 handle: 'test.bsky.social' 45 48 } 46 49 }
+53 -73
src/routes/post/[rkey]/+page.server.ts
··· 1 1 import { error, fail, redirect } from '@sveltejs/kit'; 2 2 import type { Actions, PageServerLoad } from './$types'; 3 - import { db } from '$lib/server/db'; 4 - import { posts, comments, votes } from '$lib/server/db/schema'; 5 - import { eq, asc, and, inArray } from 'drizzle-orm'; 3 + import { contentDb } from '$lib/server/db'; 4 + import { posts, comments } from '$lib/server/db/schema'; 5 + import { eq } from 'drizzle-orm'; 6 6 import { getLexClient, AuthRequiredError } from '$lib/server/lex-client'; 7 7 import { generateTid } from '$lib/server/tid'; 8 - import { cidForLex } from '@atproto/lex-cbor'; 9 8 import * as comment from '$lib/lexicons/one/papili/comment.defs'; 10 9 import type { l } from '@atproto/lex'; 11 10 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 11 + import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 12 + import { calculateHotScore } from '$lib/utils/ranking'; 12 13 13 14 export const load: PageServerLoad = async ({ params, locals }) => { 14 15 const { rkey } = params; 15 16 16 - // Find post by rkey 17 - const [post] = await db 17 + // Find post by rkey (from content DB - LiteFS replica) 18 + const [post] = await contentDb 18 19 .select() 19 20 .from(posts) 20 21 .where(eq(posts.rkey, rkey)) ··· 24 25 error(404, 'Post not found'); 25 26 } 26 27 27 - // Load comments for this post 28 - const postComments = await db 28 + // Load comments for this post (from content DB) 29 + const postComments = await contentDb 29 30 .select() 30 31 .from(comments) 31 - .where(eq(comments.postUri, post.uri)) 32 - .orderBy(asc(comments.createdAt)); 32 + .where(eq(comments.postUri, post.uri)); 33 33 34 34 // Collect all DIDs for profile fetching 35 35 const allDids = [post.authorDid, ...postComments.map((c) => c.authorDid)]; 36 36 const profiles = await fetchProfiles(allDids); 37 37 38 - // Fetch user's votes (if logged in) 39 - const userVotes = new Map<string, number>(); 40 - if (locals.did) { 41 - const allUris = [post.uri, ...postComments.map((c) => c.uri)]; 42 - const userVoteRows = await db 43 - .select({ targetUri: votes.targetUri, value: votes.value }) 44 - .from(votes) 45 - .where(and(eq(votes.userDid, locals.did), inArray(votes.targetUri, allUris))); 38 + // Get vote counts and user votes from local DB 39 + const allUris = [post.uri, ...postComments.map((c) => c.uri)]; 40 + const voteCounts = await getVoteCounts(allUris); 41 + const userVotes = locals.did ? await getUserVotes(locals.did, allUris) : new Map(); 46 42 47 - for (const vote of userVoteRows) { 48 - userVotes.set(vote.targetUri, vote.value); 49 - } 50 - } 51 - 52 - // Build comments with authors and votes 53 - const commentsWithAuthors = postComments.map((c) => ({ 54 - ...c, 55 - author: getProfileOrFallback(profiles, c.authorDid), 56 - userVote: userVotes.get(c.uri) ?? 0 57 - })); 43 + // Build comments with authors, votes, and hot scores 44 + const commentsWithAuthors = postComments 45 + .map((c) => { 46 + const voteCount = voteCounts.get(c.uri) ?? 0; 47 + return { 48 + ...c, 49 + voteCount, 50 + author: getProfileOrFallback(profiles, c.authorDid), 51 + userVote: userVotes.get(c.uri) ?? 0, 52 + hotScore: calculateHotScore(voteCount, c.createdAt) 53 + }; 54 + }) 55 + .sort((a, b) => b.hotScore - a.hotScore); 58 56 59 57 return { 60 58 post: { 61 59 ...post, 60 + voteCount: voteCounts.get(post.uri) ?? 0, 62 61 author: getProfileOrFallback(profiles, post.authorDid), 63 62 userVote: userVotes.get(post.uri) ?? 0 64 63 }, ··· 81 80 throw err; 82 81 } 83 82 84 - const authorDid = client.assertDid; 85 - 86 - // Get the post 87 - const [post] = await db 83 + // Get the post from content DB 84 + const [post] = await contentDb 88 85 .select() 89 86 .from(posts) 90 87 .where(eq(posts.rkey, rkey)) ··· 108 105 return fail(400, { error: 'Comment must be 10,000 characters or less' }); 109 106 } 110 107 108 + const authorDid = client.assertDid; 111 109 const now = new Date().toISOString(); 112 110 const commentRkey = generateTid(); 113 111 const uri = `at://${authorDid}/one.papili.comment/${commentRkey}`; 114 112 115 - // Build the record 116 - const commentRecord: comment.Main = { 117 - $type: 'one.papili.comment', 118 - post: { 119 - uri: post.uri as l.AtUriString, 120 - cid: post.cid as l.CidString 121 - }, 122 - text, 123 - createdAt: now as l.DatetimeString, 124 - ...(parentUri && parentCid 125 - ? { parent: { uri: parentUri as l.AtUriString, cid: parentCid as l.CidString } } 126 - : {}) 127 - }; 128 - 129 - // Calculate CID 130 - const cid = (await cidForLex(commentRecord)).toString(); 131 - 132 - // Write to local DB (optimistic) 133 - await db.insert(comments).values({ 134 - uri, 135 - cid, 136 - authorDid, 137 - rkey: commentRkey, 138 - postUri: post.uri, 139 - postCid: post.cid, 140 - parentUri: parentUri || null, 141 - parentCid: parentCid || null, 142 - text, 143 - createdAt: now, 144 - indexedAt: now 145 - }); 146 - 147 - // Fire-and-forget PDS write 148 - client 149 - .create( 113 + // Write to ATProto - ingester will pick it up from Jetstream 114 + try { 115 + await client.create( 150 116 comment.main, 151 117 { 152 118 post: { uri: post.uri as l.AtUriString, cid: post.cid as l.CidString }, ··· 157 123 : {}) 158 124 }, 159 125 { rkey: commentRkey } 160 - ) 161 - .catch((err) => { 162 - console.error(`[pds] Failed to write comment ${uri} to PDS:`, err); 163 - }); 126 + ); 127 + } catch (err) { 128 + console.error('[pds] Failed to create comment:', err); 129 + return fail(500, { error: 'Failed to post comment. Please try again.' }); 130 + } 164 131 165 - return { success: true }; 132 + // Return success with comment data for optimistic UI 133 + return { 134 + success: true, 135 + comment: { 136 + rkey: commentRkey, 137 + uri, 138 + authorDid, 139 + postUri: post.uri, 140 + postRkey: rkey, 141 + parentUri: parentUri ?? null, 142 + text, 143 + createdAt: now 144 + } 145 + }; 166 146 } 167 147 };
+130 -46
src/routes/post/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { enhance } from '$app/forms'; 3 + import { invalidateAll } from '$app/navigation'; 3 4 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 4 5 import Avatar from '$lib/components/Avatar.svelte'; 5 6 import VoteButton from '$lib/components/VoteButton.svelte'; 6 7 import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 7 8 import type { AuthorProfile } from '$lib/types'; 9 + import { pendingComments, type PendingComment } from '$lib/stores/pending'; 8 10 9 11 let { data, form } = $props(); 10 12 let canVote = $derived(!!data.user); ··· 14 16 let replyText = $state(''); 15 17 let collapsed = new SvelteSet<string>(); 16 18 19 + // Get pending comments for this post using $ auto-subscription 20 + const pendingStore = pendingComments.forPost(data.post.uri); 21 + let pending = $derived($pendingStore); 22 + 23 + // Reconcile pending comments when real comments change 24 + $effect(() => { 25 + const realRkeys = data.comments.map((c) => c.rkey); 26 + pendingComments.reconcile(realRkeys); 27 + }); 28 + 29 + // Total comment count including pending 30 + let totalComments = $derived(data.comments.length + pending.length); 31 + 32 + // Poll for updates while there are pending comments 33 + $effect(() => { 34 + if (pending.length === 0) return; 35 + 36 + const interval = setInterval(() => { 37 + invalidateAll(); 38 + }, 2000); 39 + 40 + return () => clearInterval(interval); 41 + }); 42 + 17 43 interface Comment { 18 44 uri: string; 19 45 cid: string; ··· 24 50 voteCount: number; 25 51 userVote: number; 26 52 author: AuthorProfile; 53 + isPending?: boolean; 27 54 } 28 55 29 56 function startReply(comment: Comment) { ··· 53 80 return count; 54 81 } 55 82 56 - // Build threaded comment structure 57 - function buildCommentTree(comments: Comment[]): SvelteMap<string | null, Comment[]> { 83 + // Build threaded comment structure, merging in pending comments 84 + function buildCommentTree(comments: Comment[], pendingComments: PendingComment[]): SvelteMap<string | null, Comment[]> { 58 85 const tree = new SvelteMap<string | null, Comment[]>(); 86 + 87 + // Track real comment rkeys to avoid duplicates 88 + const realRkeys = new Set(comments.map((c) => c.rkey)); 89 + 90 + // Add real comments 59 91 for (const comment of comments) { 60 92 const parentKey = comment.parentUri; 61 93 if (!tree.has(parentKey)) { ··· 63 95 } 64 96 tree.get(parentKey)!.push(comment); 65 97 } 98 + 99 + // Add pending comments (skip if already in real data) 100 + for (const pc of pendingComments) { 101 + if (realRkeys.has(pc.rkey)) continue; // Skip duplicates 102 + 103 + const parentKey = pc.parentUri; 104 + if (!tree.has(parentKey)) { 105 + tree.set(parentKey, []); 106 + } 107 + tree.get(parentKey)!.push({ 108 + uri: pc.uri, 109 + cid: '', // Pending comments don't have CID yet 110 + rkey: pc.rkey, 111 + text: pc.text, 112 + createdAt: pc.createdAt, 113 + parentUri: pc.parentUri, 114 + voteCount: 0, 115 + userVote: 0, 116 + author: { 117 + did: pc.authorDid as `did:${string}:${string}`, 118 + handle: (data.user?.handle ?? 'you') as `${string}.${string}`, 119 + avatar: data.user?.avatar 120 + }, 121 + isPending: true 122 + }); 123 + } 124 + 66 125 return tree; 67 126 } 68 127 69 - let commentTree = $derived(buildCommentTree(data.comments)); 128 + let commentTree = $derived(buildCommentTree(data.comments, pending)); 70 129 </script> 71 130 72 131 <svelte:head> ··· 85 144 {/if} 86 145 <div> 87 146 <h1 class="text-lg font-medium"> 88 - <a 89 - href={data.post.url} 90 - target="_blank" 91 - rel="noopener noreferrer" 92 - class="text-gray-900 dark:text-gray-100 hover:underline" 93 - > 94 - {data.post.title} 95 - </a> 96 - <span class="text-sm text-gray-400 dark:text-gray-500 font-normal ml-1"> 97 - ({getDomain(data.post.url)}) 98 - </span> 147 + {#if data.post.url} 148 + <a 149 + href={data.post.url} 150 + target="_blank" 151 + rel="noopener noreferrer" 152 + class="text-gray-900 dark:text-gray-100 hover:underline" 153 + > 154 + {data.post.title} 155 + </a> 156 + <a 157 + href="/from/{getDomain(data.post.url)}" 158 + class="text-sm text-gray-400 dark:text-gray-500 font-normal ml-1 hover:text-violet-600 dark:hover:text-violet-400" 159 + > 160 + ({getDomain(data.post.url)}) 161 + </a> 162 + {:else} 163 + <span class="text-gray-900 dark:text-gray-100">{data.post.title}</span> 164 + {/if} 99 165 </h1> 100 166 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1"> 101 167 {#if !canVote} ··· 125 191 126 192 <section class="space-y-4"> 127 193 <h2 class="text-sm font-medium text-gray-700 dark:text-gray-300"> 128 - {data.comments.length === 0 ? 'No comments yet' : `${data.comments.length} comment${data.comments.length === 1 ? '' : 's'}`} 194 + {totalComments === 0 ? 'No comments yet' : `${totalComments} comment${totalComments === 1 ? '' : 's'}`} 129 195 </h2> 130 196 131 197 <!-- Comment form --> ··· 142 208 use:enhance={() => { 143 209 submitting = true; 144 210 return async ({ update, result }) => { 145 - await update(); 146 - submitting = false; 147 - if (result.type === 'success') { 211 + if (result.type === 'success' && result.data?.success && result.data?.comment) { 212 + // Add to pending store for optimistic UI 213 + pendingComments.add(result.data.comment as Omit<PendingComment, 'submittedAt'>); 148 214 commentText = ''; 215 + } else { 216 + await update(); 149 217 } 218 + submitting = false; 150 219 }; 151 220 }} 152 221 class="space-y-2" ··· 178 247 {/if} 179 248 180 249 <!-- Comments list --> 181 - {#if data.comments.length > 0} 250 + {#if data.comments.length > 0 || pending.length > 0} 182 251 <div class="comments-tree"> 183 252 {#each commentTree.get(null) ?? [] as comment (comment.uri)} 184 253 {@render commentNode(comment, 0)} ··· 193 262 {@const isCollapsed = collapsed.has(comment.uri)} 194 263 {@const descendantCount = isCollapsed ? countDescendants(comment.uri) : 0} 195 264 196 - <div class="comment-wrapper" class:is-reply={depth > 0}> 265 + <div class="comment-wrapper" class:is-reply={depth > 0} class:is-pending={comment.isPending}> 197 266 <!-- Thread line (clickable to collapse) --> 198 267 {#if depth > 0} 199 268 <button ··· 226 295 showHandle 227 296 link 228 297 /> 229 - <span class="comment-time" title={new Date(comment.createdAt).toLocaleString()}> 230 - {formatTimeAgo(comment.createdAt, true)} 231 - </span> 232 - {#if canVote && !isCollapsed} 233 - <div class="comment-vote"> 234 - <VoteButton 235 - targetUri={comment.uri} 236 - targetType="comment" 237 - voteCount={comment.voteCount} 238 - userVote={comment.userVote} 239 - /> 240 - </div> 241 - {:else if !isCollapsed} 242 - <span class="comment-points">{comment.voteCount} pt{comment.voteCount === 1 ? '' : 's'}</span> 243 - {/if} 244 - {#if data.user && !isCollapsed} 245 - <button 246 - type="button" 247 - onclick={() => startReply(comment)} 248 - class="comment-action" 249 - > 250 - reply 251 - </button> 298 + {#if comment.isPending} 299 + <svg class="w-3 h-3 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24"> 300 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 301 + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 302 + </svg> 303 + <span class="comment-time italic">posting...</span> 304 + {:else} 305 + <span class="comment-time" title={new Date(comment.createdAt).toLocaleString()}> 306 + {formatTimeAgo(comment.createdAt, true)} 307 + </span> 308 + {#if canVote && !isCollapsed} 309 + <div class="comment-vote"> 310 + <VoteButton 311 + targetUri={comment.uri} 312 + targetType="comment" 313 + voteCount={comment.voteCount} 314 + userVote={comment.userVote} 315 + /> 316 + </div> 317 + {:else if !isCollapsed} 318 + <span class="comment-points">{comment.voteCount} pt{comment.voteCount === 1 ? '' : 's'}</span> 319 + {/if} 320 + {#if data.user && !isCollapsed} 321 + <button 322 + type="button" 323 + onclick={() => startReply(comment)} 324 + class="comment-action" 325 + > 326 + reply 327 + </button> 328 + {/if} 252 329 {/if} 253 330 </div> 254 331 ··· 271 348 use:enhance={() => { 272 349 submitting = true; 273 350 return async ({ update, result }) => { 274 - await update(); 275 - submitting = false; 276 - if (result.type === 'success') { 351 + if (result.type === 'success' && result.data?.success && result.data?.comment) { 352 + // Add to pending store for optimistic UI 353 + pendingComments.add(result.data.comment as Omit<PendingComment, 'submittedAt'>); 277 354 cancelReply(); 355 + } else { 356 + await update(); 278 357 } 358 + submitting = false; 279 359 }; 280 360 }} 281 361 class="reply-form" ··· 339 419 340 420 .comment-wrapper.is-reply { 341 421 padding-left: 1rem; 422 + } 423 + 424 + .comment-wrapper.is-pending { 425 + opacity: 0.7; 342 426 } 343 427 344 428 .thread-line {
+30 -48
src/routes/submit/+page.server.ts
··· 2 2 import type { Actions, PageServerLoad } from './$types'; 3 3 import { getLexClient, AuthRequiredError } from '$lib/server/lex-client'; 4 4 import { generateTid } from '$lib/server/tid'; 5 - import { db } from '$lib/server/db'; 6 - import { posts } from '$lib/server/db/schema'; 7 - import { cidForLex } from '@atproto/lex-cbor'; 8 5 import * as post from '$lib/lexicons/one/papili/post.defs'; 9 6 import type { l } from '@atproto/lex'; 10 7 ··· 33 30 34 31 // Parse form data 35 32 const formData = await request.formData(); 36 - const url = formData.get('url')?.toString()?.trim(); 33 + const url = formData.get('url')?.toString()?.trim() || undefined; 37 34 const title = formData.get('title')?.toString()?.trim(); 38 35 const text = formData.get('text')?.toString()?.trim() || undefined; 39 36 40 37 // Validate required fields 41 - if (!url) { 42 - return fail(400, { error: 'URL is required', url, title, text }); 43 - } 44 38 if (!title) { 45 39 return fail(400, { error: 'Title is required', url, title, text }); 46 40 } 47 41 48 - // Validate URL format 49 - try { 50 - new URL(url); 51 - } catch { 52 - return fail(400, { error: 'Invalid URL format', url, title, text }); 42 + // Validate URL format if provided 43 + if (url) { 44 + try { 45 + new URL(url); 46 + } catch { 47 + return fail(400, { error: 'Invalid URL format', url, title, text }); 48 + } 53 49 } 54 50 55 51 // Validate lengths ··· 64 60 const rkey = generateTid(); 65 61 const uri = `at://${authorDid}/one.papili.post/${rkey}`; 66 62 67 - // Build the record (AT Data Model doesn't allow undefined, so omit text if empty) 68 - const postRecord: post.Main = { 69 - $type: 'one.papili.post', 70 - url: url as l.UriString, 71 - title, 72 - createdAt: now as l.DatetimeString, 73 - ...(text ? { text } : {}) 74 - }; 75 - 76 - // Calculate CID for optimistic write 77 - const cid = (await cidForLex(postRecord)).toString(); 78 - 79 - // Step 1: Write to local DB (optimistic) 80 - await db.insert(posts).values({ 81 - uri, 82 - cid, 83 - authorDid, 84 - rkey, 85 - url, 86 - title, 87 - text, 88 - createdAt: now, 89 - indexedAt: now 90 - }); 91 - 92 - // Step 2: Fire-and-forget PDS write 93 - client 94 - .create( 63 + // Write to ATProto - ingester will pick it up from Jetstream 64 + try { 65 + await client.create( 95 66 post.main, 96 67 { 97 - url: url as l.UriString, 98 68 title, 99 69 createdAt: now as l.DatetimeString, 70 + ...(url ? { url: url as l.UriString } : {}), 100 71 ...(text ? { text } : {}) 101 72 }, 102 73 { rkey } 103 - ) 104 - .catch((err) => { 105 - console.error(`[pds] Failed to write post ${uri} to PDS:`, err); 106 - // TODO: Mark record as needing sync, add to retry queue 107 - }); 74 + ); 75 + } catch (err) { 76 + console.error('[pds] Failed to create post:', err); 77 + return fail(500, { error: 'Failed to create post. Please try again.', url, title, text }); 78 + } 108 79 109 - // Redirect to home (or to the post page once we have one) 110 - redirect(303, '/'); 80 + // Return success with post data for optimistic UI 81 + return { 82 + success: true, 83 + post: { 84 + rkey, 85 + uri, 86 + authorDid, 87 + url: url ?? null, 88 + title, 89 + text: text ?? null, 90 + createdAt: now 91 + } 92 + }; 111 93 } 112 94 };
+42 -18
src/routes/submit/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { enhance } from '$app/forms'; 3 + import { goto } from '$app/navigation'; 4 + import { pendingPosts, type PendingPost } from '$lib/stores/pending'; 3 5 4 6 let { form } = $props(); 5 7 let submitting = $state(false); ··· 10 12 </svelte:head> 11 13 12 14 <div class="max-w-xl"> 13 - <h1 class="text-lg font-bold mb-4">Submit a Link</h1> 15 + <h1 class="text-lg font-bold mb-4">Submit</h1> 14 16 15 17 {#if form?.error} 16 18 <div class="mb-4 p-3 text-sm bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded"> ··· 22 24 method="POST" 23 25 use:enhance={() => { 24 26 submitting = true; 25 - return async ({ update }) => { 26 - await update(); 27 + return async ({ result, update }) => { 28 + if (result.type === 'success' && result.data?.success && result.data?.post) { 29 + // Add to pending store for optimistic UI 30 + pendingPosts.add(result.data.post as Omit<PendingPost, 'submittedAt'>); 31 + // Navigate to home 32 + await goto('/'); 33 + } else { 34 + // Handle errors normally 35 + await update(); 36 + } 27 37 submitting = false; 28 38 }; 29 39 }} 30 40 class="space-y-4" 31 41 > 32 42 <div> 43 + <label for="title" class="block text-sm font-medium mb-1"> 44 + Title 45 + </label> 46 + <input 47 + type="text" 48 + id="title" 49 + name="title" 50 + required 51 + maxlength="300" 52 + value={form?.title ?? ''} 53 + placeholder="Title" 54 + class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent" 55 + /> 56 + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Max 300 characters</p> 57 + </div> 58 + 59 + <div> 33 60 <label for="url" class="block text-sm font-medium mb-1"> 34 - URL 61 + URL <span class="font-normal text-gray-500 dark:text-gray-400">(optional)</span> 35 62 </label> 36 63 <input 37 64 type="url" 38 65 id="url" 39 66 name="url" 40 - required 41 67 value={form?.url ?? ''} 42 68 placeholder="https://" 43 69 class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent" 44 70 /> 71 + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty for text-only posts</p> 45 72 </div> 46 73 47 74 <div> 48 - <label for="title" class="block text-sm font-medium mb-1"> 49 - Title 75 + <label for="text" class="block text-sm font-medium mb-1"> 76 + Text <span class="font-normal text-gray-500 dark:text-gray-400">(optional)</span> 50 77 </label> 51 - <input 52 - type="text" 53 - id="title" 54 - name="title" 55 - required 56 - maxlength="300" 57 - value={form?.title ?? ''} 58 - placeholder="Title of the link" 59 - class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent" 60 - /> 61 - <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Max 300 characters</p> 78 + <textarea 79 + id="text" 80 + name="text" 81 + rows="3" 82 + maxlength="10000" 83 + placeholder="Add context or commentary..." 84 + class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent resize-y" 85 + >{form?.text ?? ''}</textarea> 62 86 </div> 63 87 64 88 <div class="flex items-center gap-4 pt-2">