Skip to content

Backend Considerations

Overview

This document outlines the key technical considerations and patterns implemented in Oktuple's backend architecture to ensure scalability, consistency, and optimal performance across the distributed system.


1. Saga Pattern for Data Consistency

Overview

The Saga pattern is implemented to maintain data consistency across multiple databases and services without relying on distributed transactions, which can be complex and performance-intensive.

Implementation Strategy

Choreography-Based Saga

Compensation Actions

Rollback Strategy:

  • If Elasticsearch indexing fails, retry with exponential backoff
  • If Typesense indexing fails, mark for later retry
  • If both fail, log error and alert administrators
  • PostgreSQL remains the source of truth

Retry Mechanisms:

typescript
interface SagaStep {
  id: string;
  action: () => Promise<void>;
  compensation: () => Promise<void>;
  maxRetries: number;
  retryDelay: number;
}

class SagaOrchestrator {
  async executeSaga(steps: SagaStep[]): Promise<void> {
    const completedSteps: SagaStep[] = [];

    try {
      for (const step of steps) {
        await this.executeWithRetry(step);
        completedSteps.push(step);
      }
    } catch (error) {
      // Compensate in reverse order
      for (const step of completedSteps.reverse()) {
        await step.compensation();
      }
      throw error;
    }
  }
}

Benefits

  • Eventual Consistency: Guarantees data consistency across all systems
  • Fault Tolerance: Handles partial failures gracefully
  • Performance: No distributed locks or two-phase commits
  • Scalability: Each step can be processed independently

2. Hybrid WebSocket + REST Architecture

Design Philosophy

Oktuple implements a hybrid approach combining REST APIs for standard operations with WebSocket connections for real-time updates, providing both reliability and responsiveness.

Architecture Overview

REST API Usage

When to Use REST:

  • CRUD Operations: Create, read, update, delete operations
  • File Uploads: Large file transfers
  • Authentication: Login, logout, token refresh
  • Complex Queries: Search, filtering, pagination
  • Batch Operations: Bulk data operations

REST API Characteristics:

typescript
// Standard REST endpoint
POST /api/v1/issues
{
  "title": "New Issue",
  "description": "Issue description",
  "assigneeId": "user_123",
  "projectId": "proj_456"
}

// Response
{
  "id": "issue_789",
  "title": "New Issue",
  "status": "open",
  "createdAt": "2024-01-15T10:30:00Z"
}

WebSocket Usage

When to Use WebSocket:

  • Real-time Updates: Live collaboration features
  • Notifications: Instant alerts and messages
  • Live Cursors: Real-time cursor positions
  • Status Updates: Progress indicators and status changes
  • Presence: User online/offline status

WebSocket Message Types:

typescript
interface WebSocketMessage {
  type: 'update' | 'notification' | 'presence' | 'cursor';
  payload: any;
  timestamp: string;
  userId: string;
}

// Real-time issue update
{
  "type": "update",
  "payload": {
    "entity": "issue",
    "id": "issue_789",
    "changes": {
      "status": "in_progress",
      "assigneeId": "user_123"
    }
  },
  "timestamp": "2024-01-15T10:31:00Z",
  "userId": "user_123"
}

Implementation Strategy

Connection Management

typescript
class WebSocketManager {
  private connections = new Map<string, WebSocket>();

  async handleConnection(ws: WebSocket, userId: string) {
    this.connections.set(userId, ws);

    // Subscribe to user-specific channels
    await this.subscribeToChannels(userId);

    ws.on("close", () => {
      this.connections.delete(userId);
      this.unsubscribeFromChannels(userId);
    });
  }

  async broadcastToProject(projectId: string, message: WebSocketMessage) {
    const projectUsers = await this.getProjectUsers(projectId);

    for (const userId of projectUsers) {
      const ws = this.connections.get(userId);
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(message));
      }
    }
  }
}

Fallback Strategy

typescript
class HybridClient {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;

  async connect() {
    try {
      this.ws = new WebSocket("wss://api.oktuple.com/ws");

      this.ws.onopen = () => {
        this.reconnectAttempts = 0;
        this.subscribeToUpdates();
      };

      this.ws.onclose = () => {
        this.handleReconnection();
      };
    } catch (error) {
      // Fallback to polling
      this.startPolling();
    }
  }

  private handleReconnection() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      setTimeout(() => {
        this.reconnectAttempts++;
        this.connect();
      }, Math.pow(2, this.reconnectAttempts) * 1000);
    } else {
      // Fallback to REST polling
      this.startPolling();
    }
  }
}

Benefits

  • Reliability: REST provides fallback when WebSocket fails
  • Performance: WebSocket reduces server load for real-time features
  • User Experience: Instant updates with graceful degradation
  • Scalability: Can scale WebSocket and REST independently

3. NanoID for Unique Identifiers

Overview

NanoID is used throughout the system for generating unique, URL-safe identifiers that are more efficient than UUIDs and provide better performance characteristics.

Why NanoID?

Performance Benefits:

  • Faster Generation: 2x faster than UUID v4
  • Smaller Size: 21 characters vs 36 characters for UUID
  • URL-Safe: No special characters that need encoding
  • Collision Resistant: 1 in a billion chance of collision

Comparison:

typescript
// UUID v4
const uuid = "550e8400-e29b-41d4-a716-446655440000"; // 36 chars

// NanoID
const nanoid = "V1StGXR8_Z5jdHi6B-myT"; // 21 chars

Implementation

Custom NanoID Configuration

typescript
import { customAlphabet } from "nanoid";

// Custom alphabet for better readability
const alphabet =
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const nanoid = customAlphabet(alphabet, 12);

// Generate IDs
const userId = `user_${nanoid()}`; // user_V1StGXR8_Z5j
const projectId = `proj_${nanoid()}`; // proj_dHi6B-myT123
const issueId = `issue_${nanoid()}`; // issue_ABC123def456

Database Schema Integration

sql
-- PostgreSQL table with NanoID
CREATE TABLE issues (
    id VARCHAR(21) PRIMARY KEY DEFAULT 'issue_' || nanoid(),
    title VARCHAR(255) NOT NULL,
    description TEXT,
    project_id VARCHAR(21) REFERENCES projects(id),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Index for performance
CREATE INDEX idx_issues_project_id ON issues(project_id);
CREATE INDEX idx_issues_created_at ON issues(created_at);

Type Safety

typescript
// Type definitions
type UserID = `user_${string}`;
type ProjectID = `proj_${string}`;
type IssueID = `issue_${string}`;

interface Issue {
  id: IssueID;
  title: string;
  projectId: ProjectID;
  assigneeId?: UserID;
}

// ID validation
function isValidId(id: string, prefix: string): boolean {
  return id.startsWith(prefix) && id.length === prefix.length + 12;
}

Benefits

  • Performance: Faster generation and smaller storage
  • Readability: Shorter, more readable identifiers
  • URL-Safe: No encoding issues in URLs
  • Collision Resistant: Extremely low collision probability

4. Lexorank for High-Domain Positioning

Overview

Lexorank is used for maintaining ordered lists with high domain for insertions, allowing efficient reordering of items without updating all subsequent items.

Why Lexorank?

Traditional Approaches Problems:

  • Sequential Numbers: Requires updating all items after insertion
  • Decimal Numbers: Limited precision, requires rebalancing
  • Timestamps: Not suitable for manual ordering

Lexorank Benefits:

  • High Domain: 62^26 possible positions between any two ranks
  • Efficient Insertion: O(1) insertion between any two items
  • No Rebalancing: Rarely needs to rebalance the entire list
  • Collision Resistant: Extremely low probability of collision

Implementation

Rank Generation

typescript
class Lexorank {
  private static readonly ALPHABET =
    "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
  private static readonly BASE = 62;

  static generateRank(prevRank?: string, nextRank?: string): string {
    if (!prevRank && !nextRank) {
      return this.getMiddleRank();
    }

    if (!prevRank) {
      return this.getRankBefore(nextRank!);
    }

    if (!nextRank) {
      return this.getRankAfter(prevRank);
    }

    return this.getRankBetween(prevRank, nextRank);
  }

  private static getMiddleRank(): string {
    return "U".repeat(13); // Middle of the alphabet
  }

  private static getRankBefore(rank: string): string {
    const rankValue = this.rankToNumber(rank);
    const newValue = Math.floor(rankValue / 2);
    return this.numberToRank(newValue);
  }

  private static getRankAfter(rank: string): string {
    const rankValue = this.rankToNumber(rank);
    const newValue = Math.floor((rankValue + this.BASE ** 13) / 2);
    return this.numberToRank(newValue);
  }

  private static getRankBetween(prevRank: string, nextRank: string): string {
    const prevValue = this.rankToNumber(prevRank);
    const nextValue = this.rankToNumber(nextRank);

    if (nextValue - prevValue <= 1) {
      // Need to rebalance
      return this.rebalanceRanks(prevRank, nextRank);
    }

    const newValue = Math.floor((prevValue + nextValue) / 2);
    return this.numberToRank(newValue);
  }

  private static rankToNumber(rank: string): number {
    let value = 0;
    for (let i = 0; i < rank.length; i++) {
      value = value * this.BASE + this.ALPHABET.indexOf(rank[i]);
    }
    return value;
  }

  private static numberToRank(value: number): string {
    let rank = "";
    while (value > 0) {
      rank = this.ALPHABET[value % this.BASE] + rank;
      value = Math.floor(value / this.BASE);
    }
    return rank.padStart(13, "0");
  }
}

Database Integration

sql
-- Issues table with Lexorank
CREATE TABLE issues (
    id VARCHAR(21) PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    rank VARCHAR(13) NOT NULL,
    project_id VARCHAR(21) REFERENCES projects(id),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Index for efficient ordering
CREATE INDEX idx_issues_project_rank ON issues(project_id, rank);

API Usage

typescript
// Move issue to new position
async function moveIssue(issueId: string, newPosition: number) {
  const issue = await getIssue(issueId);
  const projectIssues = await getProjectIssues(issue.projectId);

  // Sort by rank
  projectIssues.sort((a, b) => a.rank.localeCompare(b.rank));

  let prevRank: string | undefined;
  let nextRank: string | undefined;

  if (newPosition > 0) {
    prevRank = projectIssues[newPosition - 1]?.rank;
  }

  if (newPosition < projectIssues.length - 1) {
    nextRank = projectIssues[newPosition + 1]?.rank;
  }

  const newRank = Lexorank.generateRank(prevRank, nextRank);

  await updateIssue(issueId, { rank: newRank });
}

Benefits

  • Efficient Reordering: O(1) insertion between any two items
  • High Domain: 62^26 possible positions
  • No Rebalancing: Rarely needs to rebalance entire lists
  • Collision Resistant: Extremely low collision probability

5. Unified Query Language

Overview

A unified query language provides a consistent interface for querying across multiple databases (PostgreSQL, Elasticsearch, Typesense) while abstracting the complexity of different query syntaxes.

Design Goals

  • Consistency: Same query syntax across all databases
  • Type Safety: Compile-time query validation
  • Performance: Optimized queries for each database
  • Flexibility: Support for complex queries and aggregations

Query Language Specification

Schema Types

typescript
// Define schema types
export type LogicalOperator = "AND" | "OR";
export type Operator =
  | "eq"
  | "neq"
  | "in"
  | "nin"
  | "lt"
  | "lte"
  | "gt"
  | "gte"
  | "bet"
  | "sw"
  | "ew"
  | "like"
  | "nlike"
  | "nn"
  | "null"
  | "isem"
  | "isnem"
  | "hasSome";

export interface FilterItem {
  key: string;
  op: Operator;
  value: any;
}

export interface FilterGroup {
  lo: LogicalOperator;
  items: (FilterItem | FilterGroup)[];
}

export interface SortItem {
  key: string;
  order: "asc" | "desc";
}

export interface QueryFetch {
  text_search?: string;
  filters?: FilterGroup;
  grouping?: GroupingOptions;
  order?: SortItem[];
  limit?: number;
  offset?: number;
  page?: number;
}

export interface TextSearchCols {
  textSearchCols?: string[];
}

export type RawQueryFetch = QueryFetch & TextSearchCols;

export type GroupingOptions = {
  groupBy: string;
  sizePerGroup: number;
  includeFields?: string[];
};

export interface QueryResult<T> {
  rows: T[];
  total: number | any;
  // query: Record<string, any>
  aggregations?: Record<string, any>;
  page: number;
  limit: number;
  nextToken: string | null;
  error: string | null;
}

Example Queries

typescript
// Simple query with filters
const query1: QueryFetch = {
  text_search: "bug fix",
  filters: {
    lo: "AND",
    items: [
      { key: "projectId", op: "eq", value: "proj_123" },
      { key: "status", op: "in", value: ["open", "in_progress"] },
    ],
  },
  order: [{ key: "createdAt", order: "desc" }],
  limit: 20,
  page: 1,
};

// Complex nested filter query
const query2: QueryFetch = {
  filters: {
    lo: "AND",
    items: [
      { key: "projectId", op: "eq", value: "proj_123" },
      {
        lo: "OR",
        items: [
          { key: "priority", op: "eq", value: "high" },
          { key: "assigneeId", op: "nn", value: null },
        ],
      },
    ],
  },
  grouping: {
    groupBy: "status",
    sizePerGroup: 10,
    includeFields: ["id", "title", "assigneeId"],
  },
  order: [{ key: "createdAt", order: "desc" }],
  limit: 50,
};

// Text search with specific columns
const query3: RawQueryFetch = {
  text_search: "authentication",
  textSearchCols: ["title", "description", "comments"],
  filters: {
    lo: "AND",
    items: [
      { key: "projectId", op: "eq", value: "proj_456" },
      { key: "createdAt", op: "gte", value: "2024-01-01" },
    ],
  },
  order: [{ key: "relevance", order: "desc" }],
  limit: 25,
};

Benefits

  • Consistency: Same query syntax across all databases
  • Type Safety: Compile-time validation of queries
  • Performance: Optimized queries for each database type
  • Maintainability: Single query interface to maintain
  • Flexibility: Easy to add new database support

3. Configuration Management

typescript
interface BackendConfig {
  saga: {
    maxRetries: number;
    retryDelay: number;
    compensationTimeout: number;
  };
  websocket: {
    heartbeatInterval: number;
    maxReconnectAttempts: number;
    reconnectDelay: number;
  };
  nanoid: {
    alphabet: string;
    length: number;
  };
  lexorank: {
    alphabet: string;
    base: number;
    length: number;
  };
  query: {
    defaultLimit: number;
    maxLimit: number;
    timeout: number;
  };
}

Conclusion

These backend considerations form the foundation of Oktuple's robust, scalable architecture. The combination of Saga patterns for consistency, hybrid WebSocket/REST for real-time communication, NanoID for efficient identification, Lexorank for flexible ordering, and a unified query language for database abstraction creates a powerful and maintainable system that can scale with growing requirements.

Each pattern addresses specific challenges in distributed systems while maintaining simplicity and performance. The implementation details provided serve as a guide for developers working with the system and ensure consistent application of these patterns across the codebase.