Creating Plot Twists
    Preparing search index...

    Advanced Topics

    Advanced patterns and techniques for building sophisticated Plot twists.


    Coordinate multiple external services:

    import { GitHubTool } from "@mycompany/plot-github-tool";
    import { JiraTool } from "@mycompany/plot-jira-tool";
    import { SlackTool } from "@mycompany/plot-slack-tool";

    import { twist, type Priority, type ToolBuilder } from "@plotday/twister";
    import { Plot } from "@plotday/twister/tools/plot";

    export default class DevOpsTwist extends Twist<DevOpsTwist> {
    build(build: ToolBuilder) {
    return {
    plot: build(Plot),
    github: build(GitHubTool, {
    owner: "mycompany",
    repo: "myapp",
    token: process.env.GITHUB_TOKEN!,
    }),
    slack: build(SlackTool, {
    webhookUrl: process.env.SLACK_WEBHOOK_URL!,
    }),
    jira: build(JiraTool, {
    domain: "mycompany.atlassian.net",
    apiToken: process.env.JIRA_TOKEN!,
    }),
    };
    }

    async activate(priority: Pick<Priority, "id">) {
    // Set up cross-service workflow
    await this.setupIssueSync();
    }

    async setupIssueSync() {
    // When GitHub issue is created, create Jira ticket and post to Slack
    // When Jira ticket is updated, update GitHub issue
    // When PR is merged, update both and notify Slack
    }
    }

    Implement complex workflows with state machines:

    type WorkflowState = "pending" | "in_progress" | "review" | "complete";

    interface WorkflowData {
    state: WorkflowState;
    activityId: string;
    metadata: Record<string, any>;
    }

    class WorkflowTwist extends Twist<WorkflowTwist> {
    async transitionTo(workflowId: string, newState: WorkflowState) {
    const workflow = await this.get<WorkflowData>(`workflow:${workflowId}`);
    if (!workflow) throw new Error("Workflow not found");

    const oldState = workflow.state;

    // Validate transition
    if (!this.isValidTransition(oldState, newState)) {
    throw new Error(`Invalid transition: ${oldState} -> ${newState}`);
    }

    // Execute transition logic
    await this.onExit(workflowId, oldState);

    workflow.state = newState;
    await this.set(`workflow:${workflowId}`, workflow);

    await this.onEnter(workflowId, newState);
    }

    private isValidTransition(from: WorkflowState, to: WorkflowState): boolean {
    const transitions: Record<WorkflowState, WorkflowState[]> = {
    pending: ["in_progress"],
    in_progress: ["review", "pending"],
    review: ["complete", "in_progress"],
    complete: [],
    };

    return transitions[from]?.includes(to) ?? false;
    }

    private async onEnter(workflowId: string, state: WorkflowState) {
    switch (state) {
    case "in_progress":
    await this.notifyAssigned(workflowId);
    break;
    case "review":
    await this.requestReview(workflowId);
    break;
    case "complete":
    await this.markComplete(workflowId);
    break;
    }
    }

    private async onExit(workflowId: string, state: WorkflowState) {
    // Cleanup for previous state
    }
    }

    Handle errors without breaking the twist:

    async activate(priority: Pick<Priority, "id">) {
    try {
    await this.setupWebhooks();
    } catch (error) {
    console.error("Failed to setup webhooks:", error);
    // twist still activates, just without webhooks
    // Consider creating an activity to notify the user
    await this.tools.plot.createActivity({
    type: ActivityType.Note,
    title: "⚠️ Webhook setup failed",
    note: `Could not set up automatic syncing. Error: ${error.message}`
    });
    }

    // Continue with other initialization
    await this.initialSync();
    }

    Implement exponential backoff for transient failures:

    async fetchWithRetry<T>(
    url: string,
    maxRetries: number = 3
    ): Promise<T> {
    let lastError: Error;

    for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
    const response = await fetch(url);

    if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    return await response.json();
    } catch (error) {
    lastError = error as Error;
    console.error(`Attempt ${attempt + 1} failed:`, error);

    if (attempt < maxRetries - 1) {
    // Exponential backoff: 1s, 2s, 4s
    const delay = Math.pow(2, attempt) * 1000;
    await new Promise(resolve => setTimeout(resolve, delay));
    }
    }
    }

    throw new Error(`Failed after ${maxRetries} attempts: ${lastError!.message}`);
    }

    Save state before risky operations:

    async processLargeDataset(items: Item[]) {
    for (let i = 0; i < items.length; i++) {
    try {
    await this.processItem(items[i]);

    // Save progress
    await this.set("last_processed_index", i);
    } catch (error) {
    console.error(`Error processing item ${i}:`, error);

    // Create activity for manual review
    await this.tools.plot.createActivity({
    type: ActivityType.Note,
    title: `Processing error at item ${i}`,
    note: error.message
    });

    // Continue with next item
    continue;
    }
    }
    }

    // Resume from last checkpoint
    async resumeProcessing() {
    const lastIndex = await this.get<number>("last_processed_index") || 0;
    const items = await this.get<Item[]>("items_to_process");

    if (items) {
    await this.processLargeDataset(items.slice(lastIndex + 1));
    }
    }

    Use consistent log formats:

    interface LogContext {
    twistId: string;
    priorityId?: string;
    operation: string;
    [key: string]: any;
    }

    class MyTwist extends Twist<MyTwist> {
    private log(
    level: "info" | "warn" | "error",
    message: string,
    context?: Partial<LogContext>
    ) {
    const logEntry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    twist: this.id,
    ...context,
    };

    console.log(JSON.stringify(logEntry));
    }

    async activate(priority: Pick<Priority, "id">) {
    this.log("info", "twist activating", {
    priorityId: priority.id,
    operation: "activate",
    });

    try {
    await this.setupWebhooks();
    this.log("info", "Webhooks configured successfully");
    } catch (error) {
    this.log("error", "Failed to setup webhooks", {
    error: error.message,
    stack: error.stack,
    });
    }
    }
    }

    Add debug flag for verbose logging:

    class MyTwist extends Twist<MyTwist> {
    private get debugMode(): Promise<boolean> {
    return this.get<boolean>("debug_mode").then((v) => v ?? false);
    }

    private async debug(message: string, data?: any) {
    if (await this.debugMode) {
    console.log(`[DEBUG] ${message}`, data || "");
    }
    }

    async processData(data: any) {
    await this.debug("Processing data", { itemCount: data.length });

    for (const item of data) {
    await this.debug("Processing item", item);
    await this.processItem(item);
    }
    }
    }

    Track operation durations:

    async withTiming<T>(
    operation: string,
    fn: () => Promise<T>
    ): Promise<T> {
    const start = Date.now();

    try {
    const result = await fn();
    const duration = Date.now() - start;

    console.log(`[PERF] ${operation}: ${duration}ms`);

    return result;
    } catch (error) {
    const duration = Date.now() - start;
    console.log(`[PERF] ${operation}: ${duration}ms (failed)`);
    throw error;
    }
    }

    // Usage
    await this.withTiming("sync-calendar", async () => {
    await this.syncCalendar();
    });

    Never hardcode secrets:

    // ❌ WRONG
    const apiKey = "sk-1234567890abcdef";

    // ✅ CORRECT - Use environment variables
    const apiKey = process.env.API_KEY;
    if (!apiKey) {
    throw new Error("API_KEY environment variable is required");
    }

    Validate all external input:

    async onWebhook(request: WebhookRequest) {
    // Validate signature
    if (!this.validateSignature(request)) {
    console.error("Invalid webhook signature");
    return;
    }

    // Validate schema
    if (!this.isValidPayload(request.body)) {
    console.error("Invalid webhook payload");
    return;
    }

    // Process safely
    await this.processWebhook(request.body);
    }

    private validateSignature(request: WebhookRequest): boolean {
    const signature = request.headers["x-webhook-signature"];
    const expectedSignature = this.computeSignature(request.body);
    return signature === expectedSignature;
    }

    Protect external APIs:

    class RateLimiter {
    private lastRequest: number = 0;
    private minInterval: number = 1000; // 1 request per second

    async throttle<T>(fn: () => Promise<T>): Promise<T> {
    const now = Date.now();
    const timeSinceLastRequest = now - this.lastRequest;

    if (timeSinceLastRequest < this.minInterval) {
    const delay = this.minInterval - timeSinceLastRequest;
    await new Promise(resolve => setTimeout(resolve, delay));
    }

    this.lastRequest = Date.now();
    return await fn();
    }
    }

    // Usage
    private rateLimiter = new RateLimiter();

    async fetchData() {
    return await this.rateLimiter.throttle(async () => {
    return await fetch("https://api.example.com/data");
    });
    }

    Track twist version for migrations:

    async activate(priority: Pick<Priority, "id">) {
    await this.set("twist_version", "1.0.0");
    }

    async upgrade() {
    const currentVersion = await this.get<string>("twist_version") || "0.0.0";

    if (this.compareVersions(currentVersion, "2.0.0") < 0) {
    await this.migrateToV2();
    }

    if (this.compareVersions(currentVersion, "2.1.0") < 0) {
    await this.migrateToV21();
    }

    await this.set("twist_version", "2.1.0");
    }

    Migrate stored data structures:

    async migrateToV2() {
    // V1 stored user data as separate fields
    const userId = await this.get<string>("user_id");
    const userName = await this.get<string>("user_name");
    const userEmail = await this.get<string>("user_email");

    if (userId && userName && userEmail) {
    // V2 uses a single user object
    await this.set("user", {
    id: userId,
    name: userName,
    email: userEmail
    });

    // Clean up old fields
    await this.clear("user_id");
    await this.clear("user_name");
    await this.clear("user_email");
    }
    }

    Handle breaking changes gracefully:

    async upgrade() {
    const version = await this.get<string>("version") || "1.0.0";

    if (version < "2.0.0") {
    // V2 completely changed how webhooks work
    // Clean up old webhooks
    const oldWebhooks = await this.get<string[]>("webhooks");
    if (oldWebhooks) {
    for (const webhook of oldWebhooks) {
    await this.deleteOldWebhook(webhook);
    }
    await this.clear("webhooks");
    }

    // Set up new webhook system
    await this.setupNewWebhooks();
    }

    await this.set("version", "2.0.0");
    }

    Load data only when needed:

    class MyTwist extends Twist<MyTwist> {
    private _config: Config | null = null;

    private async getConfig(): Promise<Config> {
    if (!this._config) {
    this._config = await this.get<Config>("config");
    }
    return this._config!;
    }

    async someMethod() {
    const config = await this.getConfig(); // Loaded once
    // Use config...
    }
    }

    Combine multiple similar requests:

    private pendingUserFetches = new Map<string, Promise<User>>();

    async getUser(userId: string): Promise<User> {
    // If already fetching, return existing promise
    if (this.pendingUserFetches.has(userId)) {
    return this.pendingUserFetches.get(userId)!;
    }

    // Start new fetch
    const promise = this.fetchUser(userId);
    this.pendingUserFetches.set(userId, promise);

    try {
    const user = await promise;
    return user;
    } finally {
    this.pendingUserFetches.delete(userId);
    }
    }

    Batch database operations:

    async syncAllItems(items: Item[]) {
    // ❌ SLOW - One at a time
    // for (const item of items) {
    // await this.tools.plot.createActivity({...});
    // }

    // ✅ FAST - Bulk create
    await this.tools.plot.createActivities(
    items.map(item => ({
    type: ActivityType.Task,
    title: item.title,
    note: item.description
    }))
    );
    }