···11+# Agent Guidelines for Spark AppView
22+33+## Commands
44+55+- **Format**: `deno fmt`
66+- **Lint**: `deno lint`
77+- **Test all**: `deno test -P`
88+- **Test single**: `deno test -P tests/main_test.ts`
99+- **Dev**: `deno task dev` (requires MongoDB)
1010+- **Codegen**: `deno task codegen` (generate types from lexicons)
1111+1212+## Code Style
1313+1414+- **Runtime**: Deno with TypeScript, imports use JSR/npm prefixes
1515+- **Imports**: Use absolute imports from root (e.g., `../../../lex/index.ts`),
1616+ group by external/internal
1717+- **Types**: Explicit interface definitions, use TypeScript interfaces over
1818+ types. Avoid using `any` type.
1919+- **Naming**: camelCase for variables/functions, PascalCase for
2020+ types/interfaces, UPPER_CASE for constants. Always use double quotes.
2121+- **Error handling**: Use InvalidRequestError from `@atp/xrpc-server`, log
2222+ errors before throwing
2323+- **Patterns**: Pipeline pattern (skeleton → hydration → presentation) for
2424+ endpoints in `api/`, plugin architecture for indexing in
2525+ `data-plane/indexing/`
2626+- **Database**: Mongoose models with explicit schemas, use findOneAndUpdate with
2727+ upsert for idempotency. Only interact with database directly in the
2828+ `data-plane/` directory, otherwise use the `DataPlane` API.
+129-18
README.md
···11# Spark AppView
2233-This AppView provides a view of AT Protocol that encompasses all Spark lexicon
44-and aims to interop with Bluesky lexicon.
33+An AT Protocol AppView implementation that provides a comprehensive view of the
44+Spark lexicon.
55+66+## Features
77+88+- **Real-time sync**: Subscribes to AT Protocol relay for live data ingestion
99+- **Rich API**: XRPC endpoints for feeds, profiles, audio, stories, and social
1010+ graph
1111+- **MongoDB storage**: Efficient document-based storage with Mongoose ODM
1212+- **Pipeline architecture**: Clean separation between skeleton, hydration, and
1313+ presentation layers
1414+1515+## Quick Start
1616+1717+### Docker Compose (Recommended)
51866-## Development
1919+```bash
2020+deno task docker-dev
2121+```
72288-To run with Docker Compose (includes database and appview):
99-`deno task docker-dev`. This will start both the database and appview services
1010-in Docker containers.
2323+This starts MongoDB and the AppView in Docker containers with hot reloading at
2424+`http://localhost:4000`.
11251212-For development without Docker, set up the .env file by following the
1313-instructions down below, then start the development server: `deno task dev`
2626+### Local Development
14271515-Both methods will start the server in development mode with hot reloading
1616-enabled available at `http://localhost:4000`.
2828+1. **Prerequisites**: Deno 2.x, MongoDB 8.x
17291818-## Environment Variables
3030+2. **Environment setup**: Create `.env` file (see Configuration below)
19312020-.env setup:
3232+3. **Start services**:
21333434+```bash
3535+deno task dev
2236```
3737+3838+This runs three parallel services:
3939+4040+- MongoDB (`dev:db`)
4141+- API server (`dev:api`) on port 4000
4242+- Ingester (`dev:ingest`) for real-time sync
4343+4444+## Architecture
4545+4646+### Key Directories
4747+4848+- `api/` - XRPC endpoint handlers using pipeline pattern
4949+- `data-plane/` - Database layer, indexing plugins, and subscription logic
5050+- `hydration/` - Data enrichment layer (actors, feeds, graphs)
5151+- `views/` - Presentation layer transforming hydrated data to API responses
5252+- `lexicons/` - AT Protocol lexicon definitions (JSON)
5353+- `lex/` - Generated TypeScript types from lexicons
5454+- `utils/` - Shared utilities (transformers, logger, retry logic)
5555+5656+### Data Flow
5757+5858+```
5959+AT Protocol Relay → Ingester → MongoDB ← Data Plane ← Pipeline ← API Endpoints
6060+ (Firehose) (ingest.ts) (Raw queries) (4 stages) (XRPC)
6161+```
6262+6363+### Request Pipeline (api/)
6464+6565+Every API endpoint follows a 4-stage pipeline pattern:
6666+6767+```
6868+ Client Request
6969+ │
7070+ ▼
7171+┌─────────────────────────────────────────────────────────────────┐
7272+│ 1. SKELETON │
7373+│ • Query parameters → minimal data identifiers (URIs, DIDs) │
7474+│ • Fast database queries for structure only │
7575+│ • Returns: { postUris: [...], authorDids: [...] } │
7676+└────────────────────────────────┬────────────────────────────────┘
7777+ ▼
7878+┌─────────────────────────────────────────────────────────────────┐
7979+│ 2. HYDRATION (hydration/) │
8080+│ • Skeleton → Data Plane → rich data from MongoDB │
8181+│ • Batch fetches: actors, posts, likes, blocks, etc. │
8282+│ • Returns: HydrationState with all related records │
8383+└────────────────────────────────┬────────────────────────────────┘
8484+ ▼
8585+┌─────────────────────────────────────────────────────────────────┐
8686+│ 3. RULES │
8787+│ • Apply business logic (filtering, sorting, permissions) │
8888+│ • Modify skeleton based on hydrated data │
8989+│ • Returns: Modified skeleton │
9090+└────────────────────────────────┬────────────────────────────────┘
9191+ ▼
9292+┌─────────────────────────────────────────────────────────────────┐
9393+│ 4. PRESENTATION (views/) │
9494+│ • Skeleton + Hydration → API response format │
9595+│ • Transform internal models to lexicon types │
9696+│ • Apply CDN URLs, format dates, handle takedowns │
9797+│ • Returns: JSON response matching lexicon schema │
9898+└────────────────────────────────┬────────────────────────────────┘
9999+ ▼
100100+ Client Response
101101+```
102102+103103+### Layer Responsibilities
104104+105105+**Data Plane** (`data-plane/`)
106106+107107+- Direct MongoDB access through Mongoose models
108108+- Route handlers: `actors`, `feeds`, `follows`, `likes`, `blocks`, etc.
109109+- No business logic, pure data operations
110110+- Used only by Hydrator
111111+112112+**Hydrator** (`hydration/`)
113113+114114+- Orchestrates Data Plane queries
115115+- Batches requests for efficiency
116116+- Maintains viewer context (permissions, blocks)
117117+- Returns `HydrationState` with all data needed for presentation
118118+119119+**Views** (`views/`)
120120+121121+- Pure transformation functions
122122+- No database access
123123+- Applies CDN URLs, formats responses
124124+- Enforces lexicon schemas
125125+126126+## Configuration
127127+128128+Create a `.env` file:
129129+130130+```bash
23131# Database
24132SPRK_DB_URI=mongodb://mongo:mongo@localhost:27017
133133+SPRK_DB_NAME=dev
251342626-# Server
27135NODE_ENV=development
28136SPRK_PORT=4000
2929-SPRK_PUBLIC_URL=http://localhost:3000
3030-SPRK_SERVER_DID=did:web:localhost
137137+SPRK_PUBLIC_URL=https://example.com
138138+SPRK_SERVER_DID=did:web:example.com
311393232-# Keys, generate these with openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
3333-# On Mac: openssl ecparam -name secp256k1 -genkey -noout -outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
3434-SPRK_PRIVATE_KEY=keyhex
140140+# openssl ecparam -name secp256k1 -genkey -noout -outform DER | tail -c +8 | head -c 32 | xxd -p -c 32
141141+SPRK_PRIVATE_KEY=your_private_key_hex
35142SPRK_ADMIN_PASSWORDS=password1,password2
143143+SPRK_MOD_SERVICE_DID=did:web:mod.bsky.app
144144+145145+SPRK_VERSION=0.1.0
146146+SPRK_INDEXED_AT_EPOCH=2025-01-01T00:00:00Z
36147```
+12-6
data-plane/indexing/plugins/like.ts
···139139140140const updateAggregates = async (db: Database, like: IndexedLike) => {
141141 try {
142142- // Update like count for the subject
143142 const likeCount = await db.models.Like.countDocuments({
144143 subject: like.subject,
145144 });
146145147146 const subjectUri = new AtUri(like.subject);
148147149149- // Check if this is a feed generator
150148 if (subjectUri.collection === "so.sprk.feed.generator") {
151149 const existingGenerator = await db.models.Generator.findOne({
152150 uri: like.subject,
···160158 );
161159 }
162160 } else {
163163- // Handle posts and other content types
164161 const existingPost = await db.models.Post.findOne({
165162 uri: like.subject,
166163 });
167164168165 if (existingPost) {
169169- // Only update existing posts
170166 await db.models.Post.findOneAndUpdate(
171167 { uri: like.subject },
172168 { $set: { likeCount } },
173169 { new: true },
174170 );
175171 }
176176- // We don't create a post if it doesn't exist, as we might lack required fields
172172+173173+ const existingReply = await db.models.Reply.findOne({
174174+ uri: like.subject,
175175+ });
176176+177177+ if (existingReply) {
178178+ await db.models.Reply.findOneAndUpdate(
179179+ { uri: like.subject },
180180+ { $set: { likeCount } },
181181+ { new: true },
182182+ );
183183+ }
177184 }
178185 } catch (error) {
179186 console.error("Error updating like aggregates:", error);
180180- // Don't throw - allow processing to continue even if aggregates update fails
181187 }
182188};
183189
···6868 validateThreadParams(above, below);
69697070 try {
7171- // Verify the original post exists and is a root post (posts can't have ancestors)
7171+ // Check if it's a post or reply
7272 const originalPost = await this.db.models.Post.findOne({ uri: postUri });
73737474- if (!originalPost) {
7474+ if (originalPost) {
7575+ // Posts are always root - they don't have ancestors by design
7676+ // So we only get descendants (replies)
7777+ const descendants = await getDescendants(this.db, postUri, below);
7878+7979+ // The thread is just the root post + all its descendant replies
8080+ const uris = [
8181+ postUri, // The original post (always root)
8282+ ...descendants,
8383+ ];
8484+8585+ // Remove duplicates while preserving order
8686+ const uniqueUris = Array.from(new Set(uris));
8787+8888+ return {
8989+ uris: uniqueUris,
9090+ meta: {
9191+ ancestorCount: 0, // Posts never have ancestors
9292+ descendantCount: descendants.length,
9393+ totalCount: uniqueUris.length,
9494+ },
9595+ };
9696+ }
9797+9898+ // Check if it's a reply
9999+ const originalReply = await this.db.models.Reply.findOne({
100100+ uri: postUri,
101101+ });
102102+103103+ if (!originalReply) {
75104 throw new DataPlaneError(Code.NotFound);
76105 }
771067878- // Posts are always root - they don't have ancestors by design
7979- // So we only get descendants (replies)
107107+ // Get ancestors (walking up the reply chain)
108108+ const ancestors: string[] = [];
109109+ let currentUri = postUri;
110110+ const visited = new Set<string>([currentUri]);
111111+112112+ for (let i = 0; i < above; i++) {
113113+ const current = await this.db.models.Reply.findOne({ uri: currentUri });
114114+115115+ if (!current?.reply?.parent?.uri) {
116116+ break;
117117+ }
118118+119119+ const parentUri = current.reply.parent.uri;
120120+121121+ if (visited.has(parentUri)) {
122122+ break;
123123+ }
124124+125125+ visited.add(parentUri);
126126+ ancestors.unshift(parentUri); // Add to beginning to maintain order
127127+ currentUri = parentUri;
128128+ }
129129+130130+ // Get descendants (replies to this reply)
80131 const descendants = await getDescendants(this.db, postUri, below);
811328282- // The thread is just the root post + all its descendant replies
133133+ // Build the full thread: ancestors + anchor + descendants
83134 const uris = [
8484- postUri, // The original post (always root)
135135+ ...ancestors,
136136+ postUri, // The anchor reply
85137 ...descendants,
86138 ];
87139···91143 return {
92144 uris: uniqueUris,
93145 meta: {
9494- ancestorCount: 0, // Posts never have ancestors
146146+ ancestorCount: ancestors.length,
95147 descendantCount: descendants.length,
96148 totalCount: uniqueUris.length,
97149 },
-1
hydration/feed.ts
···2121export type Posts = HydrationMap<Post>;
2222export type Reply = RecordInfo<ReplyRecord>;
2323export type Replies = HydrationMap<Reply>;
2424-2524export type Sound = RecordInfo<AudioRecord>;
2625export type Sounds = HydrationMap<Sound>;
2726