Architecture
This document explains the internal architecture of Trotsky, its design principles, and how the different components work together.
Overview
Trotsky is built around a builder pattern that allows users to chain operations (called "steps") to interact with the AT Protocol / Bluesky API. The library provides a type-safe, fluent interface for building complex automation workflows.
Core Concepts
1. Steps
A Step is the fundamental building block in Trotsky. Each step represents a single operation, such as:
- Fetching an actor profile
- Liking a post
- Following an account
- Iterating through a list
Steps are chainable and composable, allowing complex workflows to be built declaratively.
await Trotsky.init(agent)
.actor('alice.bsky.social') // Step 1: Get actor
.followers() // Step 2: Get followers
.each() // Step 3: Iterate
.follow() // Step 4: Follow each
.run() // Execute2. Step Hierarchy
Steps are organized in a class hierarchy:
Step (base class)
├── StepBuilder (chainable steps)
│ ├── Trotsky (entry point)
│ ├── StepActor
│ ├── StepPost
│ └── ...
├── StepBuilderList (steps that return lists)
│ ├── StepActors
│ ├── StepPosts
│ ├── StepActorFollowers
│ └── ...
└── StepBuilderStream (steps that stream data)
├── StepStreamPosts
└── StepActorStreamPostsKey Properties:
- Parent: Each step has a reference to its parent step
- Context: Data passed from parent to child (e.g., actor DID)
- Output: Result of executing the step
- Agent: AT Protocol agent for API calls
3. Step Types
Single-Item Steps
Steps that work with a single entity:
StepActor- Single actor profileStepPost- Single postStepList- Single list
List Steps
Steps that work with collections and support pagination:
StepActors- Multiple actorsStepPosts- Multiple postsStepActorFollowers- Actor's followers (paginated)
Action Steps
Steps that perform an action without returning data:
StepActorFollow- Follow an actorStepPostLike- Like a postStepActorBlock- Block an actor
Utility Steps
Steps that modify execution flow:
StepWhen- Conditional executionStepTap- Side effects without modifying flowStepWait- Delay executionStepSave- Save output to file
Component Organization
Directory Structure
lib/
├── core/ # Core step implementations
│ ├── base/ # Base classes (Step, StepBuilder, etc.)
│ ├── mixins/ # Reusable mixins (ActorMixins, PostMixins)
│ └── utils/ # Utilities (logger, resolvable, etc.)
├── types/ # Shared type definitions
├── errors/ # Custom error classes
├── config/ # Configuration types
└── trotsky.ts # Main barrel exportKey Files
Step.ts: Base class for all stepsStepBuilder.ts: Base for chainable stepsStepBuilderList.ts: Base for list/collection stepsTrotsky.ts: Main entry point classtrotsky.ts: Barrel export file
Design Patterns
1. Builder Pattern
The fluent interface allows chaining operations:
Trotsky.init(agent)
.actor('handle')
.posts()
.each()
.like()Each method returns a new step instance that can be chained further.
2. Mixins
Common functionality is shared via mixins:
// ActorMixins.ts
export class ActorMixins {
followers() { return this.append(StepActorFollowers) }
posts() { return this.append(StepActorPosts) }
starterPacks() { return this.append(StepActorStarterPacks) }
}
// StepActor extends both StepBuilder and ActorMixins
export class StepActor extends mix(StepBuilder, ActorMixins) {}Benefits:
- Code reuse across similar steps
- Consistent API across step types
- Easy to add new functionality
3. Context Propagation
Data flows from parent to child through the context property:
Trotsky.init(agent)
.actor('alice') // Context: { did: 'did:plc:...', handle: 'alice', ... }
.followers() // Context inherited from parent
.each() // Context: individual follower
.follow() // Uses follower's DID from context4. Lazy Execution
Steps are not executed when chained - only when .run() is called:
const workflow = Trotsky.init(agent)
.actor('alice')
.posts()
.each()
.like()
// Nothing has executed yet
await workflow.run() // NOW it executesData Flow
1. Execution Pipeline
User Code → Trotsky.init() → Chain Steps → .run() → Execute Pipeline
↓
Results/Side Effects2. Step Execution
Each step follows this lifecycle:
- Construction: Step is created via
.append() - Configuration: Parameters are set (e.g., query params)
- Context Inheritance: Receives context from parent
- Execution:
.apply()method is called - Output Generation: Result is stored in
.output - Child Execution: Child steps receive this step's output as context
3. Pagination
List steps handle pagination automatically:
async applyPagination() {
let cursor: string | undefined
const items = []
while (true) {
const response = await this.agent.api({ cursor, limit: 50 })
items.push(...response.items)
cursor = response.cursor
if (!cursor || items.length >= limit) break
}
this.output = items
}Error Handling
Trotsky provides structured error classes:
try {
await Trotsky.init(agent).actor('invalid').run()
} catch (error) {
if (error instanceof ValidationError) {
console.log(error.code, error.details)
} else if (error instanceof AuthenticationError) {
console.log('Auth failed:', error.message)
}
}Error Classes:
TrotskyError- Base error classValidationError- Input validation failuresAuthenticationError- Auth/permission failuresRateLimitError- Rate limit exceededPaginationError- Pagination failures
Type Safety
Trotsky leverages TypeScript's type system extensively:
1. Generic Type Parameters
class Step<P, C, O> {
parent: P // Parent step type
context: C // Context data type
output: O // Output data type
}2. Type Inference
Types are inferred through the chain:
const result = await Trotsky.init(agent)
.actor('alice') // StepActor<Trotsky>
.posts() // StepActorPosts<StepActor<Trotsky>>
.runHere()
// result.output is typed as AppBskyFeedDefs.PostView[]3. Shared Types
Common types are centralized in lib/types/:
import { ActorParam, PostUri, PaginationParams } from 'trotsky/types'Performance Considerations
1. Pagination Limits
Control pagination with .take():
// Only fetch first 10 items
await Trotsky.init(agent)
.actor('alice')
.followers()
.take(10)
.run()2. Rate Limiting
Built-in rate limiting (configurable):
Trotsky.init(agent, {
rateLimit: {
enabled: true,
requestsPerMinute: 60
}
})3. Batching
Some operations support batching:
// Fetch multiple posts in one request
await Trotsky.init(agent).posts([uri1, uri2, uri3]).run()Extensibility
Adding New Steps
- Create Step Class:
export class StepMyFeature extends StepBuilder {
async apply() {
const result = await this.agent.api.myFeature()
this.output = result
}
}- Add to Trotsky Class:
myFeature(): StepMyFeature<this> {
return this.append(StepMyFeature<this>)
}- Export:
// lib/trotsky.ts
export * from "./core/StepMyFeature"Using Mixins
Add reusable functionality via mixins:
export class MyMixins {
customAction() {
return this.append(StepCustomAction)
}
}
export class StepMyFeature extends mix(StepBuilder, MyMixins) {}Testing Strategy
- Unit Tests: Test individual steps in isolation
- Integration Tests: Test step chains and workflows
- Test Environment: Uses
@atproto/dev-envfor realistic testing
describe('StepActor', () => {
test('should fetch actor profile', async () => {
const actor = await Trotsky.init(agent)
.actor('alice')
.runHere()
expect(actor.output).toHaveProperty('handle')
})
})Best Practices
- Use Type Inference: Let TypeScript infer types instead of explicit annotations
- Chain Efficiently: Minimize API calls by batching when possible
- Handle Errors: Always wrap
.run()in try/catch - Rate Limit: Use
.wait()between actions to avoid rate limits - Test Workflows: Write integration tests for complex chains
Contributing
When contributing to Trotsky's architecture:
- Follow existing patterns (Step hierarchy, mixins, etc.)
- Add comprehensive JSDoc comments
- Include unit and integration tests
- Update this architecture document
- Consider backward compatibility