feat(npm): add @ruvector/raft, @ruvector/replication, @ruvector/scipix packages (#129)

- @ruvector/raft: Raft consensus implementation for distributed systems
  - Leader election and log replication
  - Fault-tolerant state machine
  - Configurable election timeouts and heartbeats

- @ruvector/replication: Data replication and synchronization
  - Multi-node replica sets with primary/secondary roles
  - Vector clocks for conflict detection
  - Sync modes: synchronous, asynchronous, semi-sync
  - Automatic failover with configurable policies

- @ruvector/scipix: OCR client for scientific documents
  - LaTeX and MathML extraction from equations
  - Batch processing support
  - Multiple output formats (LaTeX, MathML, AsciiMath, Text)

All packages built with TypeScript, fully typed, ready for npm publish.

Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rUv 2026-01-21 22:58:14 -05:00 committed by GitHub
parent 02cde18353
commit 2d2c23fceb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2807 additions and 0 deletions

View file

@ -0,0 +1,66 @@
{
"name": "@ruvector/raft",
"version": "0.1.0",
"description": "Raft consensus implementation for distributed systems - leader election, log replication, and fault tolerance",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"test": "node --test test/*.test.js",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/node": "^20.19.30",
"typescript": "^5.9.3"
},
"dependencies": {
"eventemitter3": "^5.0.4"
},
"keywords": [
"raft",
"consensus",
"distributed-systems",
"leader-election",
"log-replication",
"fault-tolerance",
"distributed-consensus",
"ruvector",
"cluster"
],
"author": "rUv Team <team@ruv.io>",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/ruvnet/ruvector.git",
"directory": "npm/packages/raft"
},
"homepage": "https://github.com/ruvnet/ruvector/tree/main/crates/ruvector-raft",
"bugs": {
"url": "https://github.com/ruvnet/ruvector/issues"
},
"engines": {
"node": ">= 18"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"files": [
"dist",
"README.md"
]
}

View file

@ -0,0 +1,78 @@
/**
* @ruvector/raft - Raft Consensus Implementation
*
* A TypeScript implementation of the Raft consensus algorithm for
* distributed systems, providing leader election, log replication,
* and fault tolerance.
*
* @example
* ```typescript
* import { RaftNode, RaftTransport, NodeState } from '@ruvector/raft';
*
* // Create a Raft node
* const node = new RaftNode({
* nodeId: 'node-1',
* peers: ['node-2', 'node-3'],
* electionTimeout: [150, 300],
* heartbeatInterval: 50,
* maxEntriesPerRequest: 100,
* });
*
* // Set up transport for RPC communication
* node.setTransport(myTransport);
*
* // Set up state machine for applying commands
* node.setStateMachine(myStateMachine);
*
* // Listen for events
* node.on('stateChange', (event) => {
* console.log(`State changed: ${event.previousState} -> ${event.newState}`);
* });
*
* node.on('leaderElected', (event) => {
* console.log(`New leader: ${event.leaderId} in term ${event.term}`);
* });
*
* // Start the node
* node.start();
*
* // Propose a command (only works if leader)
* if (node.isLeader) {
* await node.propose({ type: 'SET', key: 'foo', value: 'bar' });
* }
* ```
*
* @packageDocumentation
*/
// Types
export {
NodeId,
Term,
LogIndex,
NodeState,
LogEntry,
PersistentState,
VolatileState,
LeaderState,
RaftNodeConfig,
RequestVoteRequest,
RequestVoteResponse,
AppendEntriesRequest,
AppendEntriesResponse,
RaftError,
RaftErrorCode,
RaftEvent,
StateChangeEvent,
LeaderElectedEvent,
LogCommittedEvent,
} from './types.js';
// Log
export { RaftLog } from './log.js';
// State
export { RaftState } from './state.js';
// Node
export { RaftNode, RaftTransport, StateMachine } from './node.js';

View file

@ -0,0 +1,135 @@
/**
* Raft Log Implementation
* Manages the replicated log with persistence support
*/
import { LogEntry, LogIndex, Term } from './types.js';
/** In-memory log storage with optional persistence callback */
export class RaftLog<T = unknown> {
private entries: LogEntry<T>[] = [];
private persistCallback?: (entries: LogEntry<T>[]) => Promise<void>;
constructor(options?: { onPersist?: (entries: LogEntry<T>[]) => Promise<void> }) {
this.persistCallback = options?.onPersist;
}
/** Get the last log index */
get lastIndex(): LogIndex {
return this.entries.length > 0 ? this.entries[this.entries.length - 1].index : 0;
}
/** Get the last log term */
get lastTerm(): Term {
return this.entries.length > 0 ? this.entries[this.entries.length - 1].term : 0;
}
/** Get log length */
get length(): number {
return this.entries.length;
}
/** Get entry at index */
get(index: LogIndex): LogEntry<T> | undefined {
return this.entries.find((e) => e.index === index);
}
/** Get term at index */
termAt(index: LogIndex): Term | undefined {
if (index === 0) return 0;
const entry = this.get(index);
return entry?.term;
}
/** Append entries to log */
async append(entries: LogEntry<T>[]): Promise<void> {
if (entries.length === 0) return;
// Find where to start appending (handle conflicting entries)
for (const entry of entries) {
const existing = this.get(entry.index);
if (existing) {
if (existing.term !== entry.term) {
// Conflict: delete this and all following entries
this.truncateFrom(entry.index);
} else {
// Same entry, skip
continue;
}
}
this.entries.push(entry);
}
// Sort by index to maintain order
this.entries.sort((a, b) => a.index - b.index);
if (this.persistCallback) {
await this.persistCallback(this.entries);
}
}
/** Append a single command, returning the new entry */
async appendCommand(term: Term, command: T): Promise<LogEntry<T>> {
const entry: LogEntry<T> = {
term,
index: this.lastIndex + 1,
command,
timestamp: Date.now(),
};
await this.append([entry]);
return entry;
}
/** Get entries starting from index */
getFrom(startIndex: LogIndex, maxCount?: number): LogEntry<T>[] {
const result: LogEntry<T>[] = [];
for (const entry of this.entries) {
if (entry.index >= startIndex) {
result.push(entry);
if (maxCount && result.length >= maxCount) break;
}
}
return result;
}
/** Get entries in range [start, end] */
getRange(startIndex: LogIndex, endIndex: LogIndex): LogEntry<T>[] {
return this.entries.filter((e) => e.index >= startIndex && e.index <= endIndex);
}
/** Truncate log from index (remove index and all following) */
truncateFrom(index: LogIndex): void {
this.entries = this.entries.filter((e) => e.index < index);
}
/** Check if log is at least as up-to-date as given term/index */
isUpToDate(lastLogTerm: Term, lastLogIndex: LogIndex): boolean {
if (this.lastTerm !== lastLogTerm) {
return this.lastTerm > lastLogTerm;
}
return this.lastIndex >= lastLogIndex;
}
/** Check if log contains entry at index with matching term */
containsEntry(index: LogIndex, term: Term): boolean {
if (index === 0) return true;
const entry = this.get(index);
return entry?.term === term;
}
/** Get all entries */
getAll(): LogEntry<T>[] {
return [...this.entries];
}
/** Clear all entries */
clear(): void {
this.entries = [];
}
/** Load entries from storage */
load(entries: LogEntry<T>[]): void {
this.entries = [...entries];
this.entries.sort((a, b) => a.index - b.index);
}
}

View file

@ -0,0 +1,435 @@
/**
* Raft Node Implementation
* Core Raft consensus algorithm implementation
*/
import EventEmitter from 'eventemitter3';
import {
NodeId,
Term,
LogIndex,
NodeState,
RaftNodeConfig,
RequestVoteRequest,
RequestVoteResponse,
AppendEntriesRequest,
AppendEntriesResponse,
LogEntry,
RaftError,
RaftEvent,
StateChangeEvent,
LeaderElectedEvent,
LogCommittedEvent,
PersistentState,
} from './types.js';
import { RaftState } from './state.js';
/** Transport interface for sending RPCs to peers */
export interface RaftTransport<T = unknown> {
/** Send RequestVote RPC to a peer */
requestVote(peerId: NodeId, request: RequestVoteRequest): Promise<RequestVoteResponse>;
/** Send AppendEntries RPC to a peer */
appendEntries(peerId: NodeId, request: AppendEntriesRequest<T>): Promise<AppendEntriesResponse>;
}
/** State machine interface for applying committed entries */
export interface StateMachine<T = unknown, R = void> {
/** Apply a committed command to the state machine */
apply(command: T): Promise<R>;
}
/** Default configuration values */
const DEFAULT_CONFIG: Partial<RaftNodeConfig> = {
electionTimeout: [150, 300],
heartbeatInterval: 50,
maxEntriesPerRequest: 100,
};
/** Raft consensus node */
export class RaftNode<T = unknown, R = void> extends EventEmitter {
private readonly config: Required<RaftNodeConfig>;
private readonly state: RaftState<T>;
private nodeState: NodeState = NodeState.Follower;
private leaderId: NodeId | null = null;
private transport: RaftTransport<T> | null = null;
private stateMachine: StateMachine<T, R> | null = null;
private electionTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(config: RaftNodeConfig) {
super();
this.config = { ...DEFAULT_CONFIG, ...config } as Required<RaftNodeConfig>;
this.state = new RaftState<T>(config.nodeId, config.peers);
}
/** Get node ID */
get nodeId(): NodeId {
return this.config.nodeId;
}
/** Get current state */
get currentState(): NodeState {
return this.nodeState;
}
/** Get current term */
get currentTerm(): Term {
return this.state.currentTerm;
}
/** Get current leader ID */
get leader(): NodeId | null {
return this.leaderId;
}
/** Check if this node is the leader */
get isLeader(): boolean {
return this.nodeState === NodeState.Leader;
}
/** Get commit index */
get commitIndex(): LogIndex {
return this.state.commitIndex;
}
/** Set transport for RPC communication */
setTransport(transport: RaftTransport<T>): void {
this.transport = transport;
}
/** Set state machine for applying commands */
setStateMachine(stateMachine: StateMachine<T, R>): void {
this.stateMachine = stateMachine;
}
/** Start the Raft node */
start(): void {
if (this.running) return;
this.running = true;
this.resetElectionTimer();
}
/** Stop the Raft node */
stop(): void {
this.running = false;
this.clearTimers();
}
/** Propose a command to be replicated (only works if leader) */
async propose(command: T): Promise<LogEntry<T>> {
if (this.nodeState !== NodeState.Leader) {
throw RaftError.notLeader();
}
const entry = await this.state.log.appendCommand(this.state.currentTerm, command);
this.emit(RaftEvent.LogAppended, entry);
// Immediately replicate to followers
await this.replicateToFollowers();
return entry;
}
/** Handle RequestVote RPC from a candidate */
async handleRequestVote(request: RequestVoteRequest): Promise<RequestVoteResponse> {
// If request term is higher, update term and become follower
if (request.term > this.state.currentTerm) {
await this.state.setTerm(request.term);
this.transitionTo(NodeState.Follower);
}
// Deny vote if request term is less than current term
if (request.term < this.state.currentTerm) {
return { term: this.state.currentTerm, voteGranted: false };
}
// Check if we can vote for this candidate
const canVote =
(this.state.votedFor === null || this.state.votedFor === request.candidateId) &&
this.state.log.isUpToDate(request.lastLogTerm, request.lastLogIndex);
if (canVote) {
await this.state.vote(request.term, request.candidateId);
this.resetElectionTimer();
this.emit(RaftEvent.VoteGranted, { candidateId: request.candidateId, term: request.term });
return { term: this.state.currentTerm, voteGranted: true };
}
return { term: this.state.currentTerm, voteGranted: false };
}
/** Handle AppendEntries RPC from leader */
async handleAppendEntries(request: AppendEntriesRequest<T>): Promise<AppendEntriesResponse> {
// If request term is higher, update term
if (request.term > this.state.currentTerm) {
await this.state.setTerm(request.term);
this.transitionTo(NodeState.Follower);
}
// Reject if term is less than current term
if (request.term < this.state.currentTerm) {
return { term: this.state.currentTerm, success: false };
}
// Valid leader - reset election timer
this.leaderId = request.leaderId;
this.resetElectionTimer();
// If not follower, become follower
if (this.nodeState !== NodeState.Follower) {
this.transitionTo(NodeState.Follower);
}
this.emit(RaftEvent.Heartbeat, { leaderId: request.leaderId, term: request.term });
// Check if log contains entry at prevLogIndex with prevLogTerm
if (request.prevLogIndex > 0 && !this.state.log.containsEntry(request.prevLogIndex, request.prevLogTerm)) {
return { term: this.state.currentTerm, success: false };
}
// Append entries
if (request.entries.length > 0) {
await this.state.log.append(request.entries);
}
// Update commit index
if (request.leaderCommit > this.state.commitIndex) {
this.state.setCommitIndex(
Math.min(request.leaderCommit, this.state.log.lastIndex),
);
await this.applyCommitted();
}
return {
term: this.state.currentTerm,
success: true,
matchIndex: this.state.log.lastIndex,
};
}
/** Load persistent state */
loadState(state: PersistentState<T>): void {
this.state.loadPersistentState(state);
}
/** Get current persistent state */
getState(): PersistentState<T> {
return this.state.getPersistentState();
}
// Private methods
private transitionTo(newState: NodeState): void {
const previousState = this.nodeState;
if (previousState === newState) return;
this.nodeState = newState;
this.clearTimers();
if (newState === NodeState.Leader) {
this.state.initLeaderState();
this.leaderId = this.config.nodeId;
this.startHeartbeat();
this.emit(RaftEvent.LeaderElected, {
leaderId: this.config.nodeId,
term: this.state.currentTerm,
} as LeaderElectedEvent);
} else {
this.state.clearLeaderState();
if (newState === NodeState.Follower) {
this.leaderId = null;
this.resetElectionTimer();
}
}
this.emit(RaftEvent.StateChange, {
previousState,
newState,
term: this.state.currentTerm,
} as StateChangeEvent);
}
private getRandomElectionTimeout(): number {
const [min, max] = this.config.electionTimeout;
return min + Math.random() * (max - min);
}
private resetElectionTimer(): void {
if (this.electionTimer) {
clearTimeout(this.electionTimer);
}
if (!this.running) return;
this.electionTimer = setTimeout(() => {
this.startElection();
}, this.getRandomElectionTimeout());
}
private clearTimers(): void {
if (this.electionTimer) {
clearTimeout(this.electionTimer);
this.electionTimer = null;
}
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private async startElection(): Promise<void> {
if (!this.running) return;
// Increment term and become candidate
await this.state.setTerm(this.state.currentTerm + 1);
await this.state.vote(this.state.currentTerm, this.config.nodeId);
this.transitionTo(NodeState.Candidate);
this.emit(RaftEvent.VoteRequested, {
term: this.state.currentTerm,
candidateId: this.config.nodeId,
});
// Start with 1 vote (self)
let votesReceived = 1;
const majority = Math.floor((this.config.peers.length + 1) / 2) + 1;
// Request votes from all peers
if (!this.transport) {
this.resetElectionTimer();
return;
}
const votePromises = this.config.peers.map(async (peerId) => {
try {
const response = await this.transport!.requestVote(peerId, {
term: this.state.currentTerm,
candidateId: this.config.nodeId,
lastLogIndex: this.state.log.lastIndex,
lastLogTerm: this.state.log.lastTerm,
});
// If response term is higher, become follower
if (response.term > this.state.currentTerm) {
await this.state.setTerm(response.term);
this.transitionTo(NodeState.Follower);
return;
}
if (response.voteGranted && this.nodeState === NodeState.Candidate) {
votesReceived++;
if (votesReceived >= majority) {
this.transitionTo(NodeState.Leader);
}
}
} catch {
// Peer unavailable, continue
}
});
await Promise.allSettled(votePromises);
// If still candidate, restart election timer
if (this.nodeState === NodeState.Candidate) {
this.resetElectionTimer();
}
}
private startHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
}
// Send immediate heartbeat
this.replicateToFollowers();
// Start periodic heartbeat
this.heartbeatTimer = setInterval(() => {
if (this.nodeState === NodeState.Leader) {
this.replicateToFollowers();
}
}, this.config.heartbeatInterval);
}
private async replicateToFollowers(): Promise<void> {
if (!this.transport || this.nodeState !== NodeState.Leader) return;
const replicationPromises = this.config.peers.map(async (peerId) => {
await this.replicateToPeer(peerId);
});
await Promise.allSettled(replicationPromises);
// Update commit index if majority have replicated
if (this.state.updateCommitIndex()) {
this.emit(RaftEvent.LogCommitted, {
index: this.state.commitIndex,
term: this.state.currentTerm,
} as LogCommittedEvent);
await this.applyCommitted();
}
}
private async replicateToPeer(peerId: NodeId): Promise<void> {
if (!this.transport || this.nodeState !== NodeState.Leader) return;
const nextIndex = this.state.getNextIndex(peerId);
const prevLogIndex = nextIndex - 1;
const prevLogTerm = this.state.log.termAt(prevLogIndex) ?? 0;
const entries = this.state.log.getFrom(nextIndex, this.config.maxEntriesPerRequest);
try {
const response = await this.transport.appendEntries(peerId, {
term: this.state.currentTerm,
leaderId: this.config.nodeId,
prevLogIndex,
prevLogTerm,
entries,
leaderCommit: this.state.commitIndex,
});
if (response.term > this.state.currentTerm) {
await this.state.setTerm(response.term);
this.transitionTo(NodeState.Follower);
return;
}
if (response.success) {
if (response.matchIndex !== undefined) {
this.state.setNextIndex(peerId, response.matchIndex + 1);
this.state.setMatchIndex(peerId, response.matchIndex);
} else if (entries.length > 0) {
const lastEntry = entries[entries.length - 1];
this.state.setNextIndex(peerId, lastEntry.index + 1);
this.state.setMatchIndex(peerId, lastEntry.index);
}
} else {
// Decrement nextIndex and retry
this.state.setNextIndex(peerId, nextIndex - 1);
}
} catch {
// Peer unavailable, will retry on next heartbeat
}
}
private async applyCommitted(): Promise<void> {
while (this.state.lastApplied < this.state.commitIndex) {
const nextIndex = this.state.lastApplied + 1;
const entry = this.state.log.get(nextIndex);
if (entry && this.stateMachine) {
try {
await this.stateMachine.apply(entry.command);
this.state.setLastApplied(nextIndex);
this.emit(RaftEvent.LogApplied, entry);
} catch (error) {
this.emit(RaftEvent.Error, error);
break;
}
} else {
this.state.setLastApplied(nextIndex);
}
}
}
}

View file

@ -0,0 +1,200 @@
/**
* Raft State Management
* Manages persistent and volatile state for Raft consensus
*/
import type {
NodeId,
Term,
LogIndex,
PersistentState,
VolatileState,
LeaderState,
LogEntry,
} from './types.js';
import { RaftLog } from './log.js';
/** State manager for a Raft node */
export class RaftState<T = unknown> {
private _currentTerm: Term = 0;
private _votedFor: NodeId | null = null;
private _commitIndex: LogIndex = 0;
private _lastApplied: LogIndex = 0;
private _leaderState: LeaderState | null = null;
public readonly log: RaftLog<T>;
constructor(
private readonly nodeId: NodeId,
private readonly peers: NodeId[],
options?: {
onPersist?: (state: PersistentState<T>) => Promise<void>;
onLogPersist?: (entries: LogEntry<T>[]) => Promise<void>;
},
) {
this.log = new RaftLog({ onPersist: options?.onLogPersist });
this.persistCallback = options?.onPersist;
}
private persistCallback?: (state: PersistentState<T>) => Promise<void>;
/** Get current term */
get currentTerm(): Term {
return this._currentTerm;
}
/** Get voted for */
get votedFor(): NodeId | null {
return this._votedFor;
}
/** Get commit index */
get commitIndex(): LogIndex {
return this._commitIndex;
}
/** Get last applied */
get lastApplied(): LogIndex {
return this._lastApplied;
}
/** Get leader state (null if not leader) */
get leaderState(): LeaderState | null {
return this._leaderState;
}
/** Update term (with persistence) */
async setTerm(term: Term): Promise<void> {
if (term > this._currentTerm) {
this._currentTerm = term;
this._votedFor = null;
await this.persist();
}
}
/** Record vote (with persistence) */
async vote(term: Term, candidateId: NodeId): Promise<void> {
this._currentTerm = term;
this._votedFor = candidateId;
await this.persist();
}
/** Update commit index */
setCommitIndex(index: LogIndex): void {
if (index > this._commitIndex) {
this._commitIndex = index;
}
}
/** Update last applied */
setLastApplied(index: LogIndex): void {
if (index > this._lastApplied) {
this._lastApplied = index;
}
}
/** Initialize leader state */
initLeaderState(): void {
const nextIndex = new Map<NodeId, LogIndex>();
const matchIndex = new Map<NodeId, LogIndex>();
for (const peer of this.peers) {
// Initialize nextIndex to leader's last log index + 1
nextIndex.set(peer, this.log.lastIndex + 1);
// Initialize matchIndex to 0
matchIndex.set(peer, 0);
}
this._leaderState = { nextIndex, matchIndex };
}
/** Clear leader state */
clearLeaderState(): void {
this._leaderState = null;
}
/** Update nextIndex for a peer */
setNextIndex(peerId: NodeId, index: LogIndex): void {
if (this._leaderState) {
this._leaderState.nextIndex.set(peerId, Math.max(1, index));
}
}
/** Update matchIndex for a peer */
setMatchIndex(peerId: NodeId, index: LogIndex): void {
if (this._leaderState) {
this._leaderState.matchIndex.set(peerId, index);
}
}
/** Get nextIndex for a peer */
getNextIndex(peerId: NodeId): LogIndex {
return this._leaderState?.nextIndex.get(peerId) ?? this.log.lastIndex + 1;
}
/** Get matchIndex for a peer */
getMatchIndex(peerId: NodeId): LogIndex {
return this._leaderState?.matchIndex.get(peerId) ?? 0;
}
/** Update commit index based on match indices (for leader) */
updateCommitIndex(): boolean {
if (!this._leaderState) return false;
// Find the highest index N such that a majority have matchIndex >= N
// and log[N].term == currentTerm
const matchIndices = Array.from(this._leaderState.matchIndex.values());
matchIndices.push(this.log.lastIndex); // Include self
matchIndices.sort((a, b) => b - a); // Sort descending
const majority = Math.floor((this.peers.length + 1) / 2) + 1;
for (const index of matchIndices) {
if (index <= this._commitIndex) break;
const term = this.log.termAt(index);
if (term === this._currentTerm) {
// Count how many have this index or higher
const count =
matchIndices.filter((m) => m >= index).length + 1; // +1 for self
if (count >= majority) {
this._commitIndex = index;
return true;
}
}
}
return false;
}
/** Get persistent state */
getPersistentState(): PersistentState<T> {
return {
currentTerm: this._currentTerm,
votedFor: this._votedFor,
log: this.log.getAll(),
};
}
/** Get volatile state */
getVolatileState(): VolatileState {
return {
commitIndex: this._commitIndex,
lastApplied: this._lastApplied,
};
}
/** Load persistent state */
loadPersistentState(state: PersistentState<T>): void {
this._currentTerm = state.currentTerm;
this._votedFor = state.votedFor;
this.log.load(state.log);
}
/** Persist state */
private async persist(): Promise<void> {
if (this.persistCallback) {
await this.persistCallback(this.getPersistentState());
}
}
}

View file

@ -0,0 +1,189 @@
/**
* Raft Consensus Types
* Based on the Raft paper specification
*/
/** Unique identifier for a node in the cluster */
export type NodeId = string;
/** Monotonically increasing term number */
export type Term = number;
/** Index into the replicated log */
export type LogIndex = number;
/** Possible states of a Raft node */
export enum NodeState {
Follower = 'follower',
Candidate = 'candidate',
Leader = 'leader',
}
/** Entry in the replicated log */
export interface LogEntry<T = unknown> {
/** Term when entry was received by leader */
term: Term;
/** Index in the log */
index: LogIndex;
/** Command to be applied to state machine */
command: T;
/** Timestamp when entry was created */
timestamp: number;
}
/** Persistent state on all servers (updated on stable storage before responding to RPCs) */
export interface PersistentState<T = unknown> {
/** Latest term server has seen */
currentTerm: Term;
/** CandidateId that received vote in current term (or null if none) */
votedFor: NodeId | null;
/** Log entries */
log: LogEntry<T>[];
}
/** Volatile state on all servers */
export interface VolatileState {
/** Index of highest log entry known to be committed */
commitIndex: LogIndex;
/** Index of highest log entry applied to state machine */
lastApplied: LogIndex;
}
/** Volatile state on leaders (reinitialized after election) */
export interface LeaderState {
/** For each server, index of the next log entry to send to that server */
nextIndex: Map<NodeId, LogIndex>;
/** For each server, index of highest log entry known to be replicated on server */
matchIndex: Map<NodeId, LogIndex>;
}
/** Configuration for a Raft node */
export interface RaftNodeConfig {
/** Unique identifier for this node */
nodeId: NodeId;
/** List of all node IDs in the cluster */
peers: NodeId[];
/** Election timeout range in milliseconds [min, max] */
electionTimeout: [number, number];
/** Heartbeat interval in milliseconds */
heartbeatInterval: number;
/** Maximum entries per AppendEntries RPC */
maxEntriesPerRequest: number;
}
/** Request for RequestVote RPC */
export interface RequestVoteRequest {
/** Candidate's term */
term: Term;
/** Candidate requesting vote */
candidateId: NodeId;
/** Index of candidate's last log entry */
lastLogIndex: LogIndex;
/** Term of candidate's last log entry */
lastLogTerm: Term;
}
/** Response for RequestVote RPC */
export interface RequestVoteResponse {
/** Current term, for candidate to update itself */
term: Term;
/** True means candidate received vote */
voteGranted: boolean;
}
/** Request for AppendEntries RPC */
export interface AppendEntriesRequest<T = unknown> {
/** Leader's term */
term: Term;
/** So follower can redirect clients */
leaderId: NodeId;
/** Index of log entry immediately preceding new ones */
prevLogIndex: LogIndex;
/** Term of prevLogIndex entry */
prevLogTerm: Term;
/** Log entries to store (empty for heartbeat) */
entries: LogEntry<T>[];
/** Leader's commitIndex */
leaderCommit: LogIndex;
}
/** Response for AppendEntries RPC */
export interface AppendEntriesResponse {
/** Current term, for leader to update itself */
term: Term;
/** True if follower contained entry matching prevLogIndex and prevLogTerm */
success: boolean;
/** Hint for next index to try (optimization) */
matchIndex?: LogIndex;
}
/** Raft error types */
export class RaftError extends Error {
constructor(
message: string,
public readonly code: RaftErrorCode,
) {
super(message);
this.name = 'RaftError';
}
static notLeader(): RaftError {
return new RaftError('Node is not the leader', RaftErrorCode.NotLeader);
}
static noLeader(): RaftError {
return new RaftError('No leader available', RaftErrorCode.NoLeader);
}
static electionTimeout(): RaftError {
return new RaftError('Election timeout', RaftErrorCode.ElectionTimeout);
}
static logInconsistency(): RaftError {
return new RaftError('Log inconsistency detected', RaftErrorCode.LogInconsistency);
}
}
export enum RaftErrorCode {
NotLeader = 'NOT_LEADER',
NoLeader = 'NO_LEADER',
InvalidTerm = 'INVALID_TERM',
InvalidLogIndex = 'INVALID_LOG_INDEX',
ElectionTimeout = 'ELECTION_TIMEOUT',
LogInconsistency = 'LOG_INCONSISTENCY',
SnapshotFailed = 'SNAPSHOT_FAILED',
ConfigError = 'CONFIG_ERROR',
Internal = 'INTERNAL',
}
/** Event types emitted by RaftNode */
export enum RaftEvent {
StateChange = 'stateChange',
LeaderElected = 'leaderElected',
LogAppended = 'logAppended',
LogCommitted = 'logCommitted',
LogApplied = 'logApplied',
VoteRequested = 'voteRequested',
VoteGranted = 'voteGranted',
Heartbeat = 'heartbeat',
Error = 'error',
}
/** State change event data */
export interface StateChangeEvent {
previousState: NodeState;
newState: NodeState;
term: Term;
}
/** Leader elected event data */
export interface LeaderElectedEvent {
leaderId: NodeId;
term: Term;
}
/** Log committed event data */
export interface LogCommittedEvent {
index: LogIndex;
term: Term;
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View file

@ -0,0 +1,67 @@
{
"name": "@ruvector/replication",
"version": "0.1.0",
"description": "Data replication and synchronization - multi-node replicas, conflict resolution with vector clocks, change data capture, and automatic failover",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"test": "node --test test/*.test.js",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/node": "^20.19.30",
"typescript": "^5.9.3"
},
"dependencies": {
"eventemitter3": "^5.0.4"
},
"keywords": [
"replication",
"synchronization",
"distributed-systems",
"conflict-resolution",
"vector-clock",
"crdt",
"change-data-capture",
"failover",
"high-availability",
"ruvector"
],
"author": "rUv Team <team@ruv.io>",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/ruvnet/ruvector.git",
"directory": "npm/packages/replication"
},
"homepage": "https://github.com/ruvnet/ruvector/tree/main/crates/ruvector-replication",
"bugs": {
"url": "https://github.com/ruvnet/ruvector/issues"
},
"engines": {
"node": ">= 18"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"files": [
"dist",
"README.md"
]
}

View file

@ -0,0 +1,83 @@
/**
* @ruvector/replication - Data Replication and Synchronization
*
* A TypeScript implementation of data replication capabilities including:
* - Multi-node replica management
* - Synchronous, asynchronous, and semi-synchronous replication modes
* - Conflict resolution with vector clocks
* - Change data capture and streaming
* - Automatic failover and split-brain prevention
*
* @example
* ```typescript
* import {
* ReplicaSet,
* ReplicaRole,
* SyncManager,
* ReplicationLog,
* SyncMode,
* ChangeOperation,
* } from '@ruvector/replication';
*
* // Create a replica set
* const replicaSet = new ReplicaSet('my-cluster');
*
* // Add replicas
* replicaSet.addReplica('replica-1', '192.168.1.10:9001', ReplicaRole.Primary);
* replicaSet.addReplica('replica-2', '192.168.1.11:9001', ReplicaRole.Secondary);
* replicaSet.addReplica('replica-3', '192.168.1.12:9001', ReplicaRole.Secondary);
*
* // Create replication log and sync manager
* const log = new ReplicationLog('replica-1');
* const syncManager = new SyncManager(replicaSet, log);
*
* // Configure semi-sync replication
* syncManager.setSyncMode(SyncMode.SemiSync, 1);
*
* // Listen for events
* syncManager.on('changeReceived', (change) => {
* console.log(`Change: ${change.operation} on ${change.key}`);
* });
*
* // Record a change
* await syncManager.recordChange('user:123', ChangeOperation.Update, { name: 'Alice' });
* ```
*
* @packageDocumentation
*/
// Types
export {
ReplicaId,
LogicalClock,
ReplicaRole,
ReplicaStatus,
SyncMode,
HealthStatus,
Replica,
ChangeOperation,
ChangeEvent,
VectorClockValue,
LogEntry,
FailoverPolicy,
ReplicationError,
ReplicationErrorCode,
ReplicationEvent,
ReplicaSetConfig,
SyncConfig,
} from './types.js';
// Vector Clock
export {
VectorClock,
VectorClockComparison,
ConflictResolver,
LastWriteWins,
MergeFunction,
} from './vector-clock.js';
// Replica Set
export { ReplicaSet } from './replica-set.js';
// Sync Manager
export { SyncManager, ReplicationLog } from './sync-manager.js';

View file

@ -0,0 +1,254 @@
/**
* Replica Set Management
* Manages a set of replicas for distributed data storage
*/
import EventEmitter from 'eventemitter3';
import {
type Replica,
type ReplicaId,
type ReplicaSetConfig,
ReplicaRole,
ReplicaStatus,
ReplicationError,
ReplicationEvent,
FailoverPolicy,
} from './types.js';
/** Default configuration */
const DEFAULT_CONFIG: ReplicaSetConfig = {
name: 'default',
minQuorum: 2,
heartbeatInterval: 1000,
healthCheckTimeout: 5000,
failoverPolicy: FailoverPolicy.Automatic,
};
/** Manages a set of replicas */
export class ReplicaSet extends EventEmitter {
private replicas: Map<ReplicaId, Replica> = new Map();
private config: ReplicaSetConfig;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
constructor(name: string, config?: Partial<ReplicaSetConfig>) {
super();
this.config = { ...DEFAULT_CONFIG, name, ...config };
}
/** Get replica set name */
get name(): string {
return this.config.name;
}
/** Get the primary replica */
get primary(): Replica | undefined {
for (const replica of this.replicas.values()) {
if (replica.role === ReplicaRole.Primary && replica.status === ReplicaStatus.Active) {
return replica;
}
}
return undefined;
}
/** Get all secondary replicas */
get secondaries(): Replica[] {
return Array.from(this.replicas.values()).filter(
(r) => r.role === ReplicaRole.Secondary && r.status === ReplicaStatus.Active,
);
}
/** Get all active replicas */
get activeReplicas(): Replica[] {
return Array.from(this.replicas.values()).filter((r) => r.status === ReplicaStatus.Active);
}
/** Get replica count */
get size(): number {
return this.replicas.size;
}
/** Check if quorum is met */
get hasQuorum(): boolean {
const activeCount = this.activeReplicas.length;
return activeCount >= this.config.minQuorum;
}
/** Add a replica to the set */
addReplica(id: ReplicaId, address: string, role: ReplicaRole): Replica {
if (this.replicas.has(id)) {
throw new Error(`Replica ${id} already exists`);
}
// Check if adding a primary when one exists
if (role === ReplicaRole.Primary && this.primary) {
throw new Error('Primary already exists in replica set');
}
const replica: Replica = {
id,
address,
role,
status: ReplicaStatus.Active,
lastSeen: Date.now(),
lag: 0,
};
this.replicas.set(id, replica);
this.emit(ReplicationEvent.ReplicaAdded, replica);
return replica;
}
/** Remove a replica from the set */
removeReplica(id: ReplicaId): boolean {
const replica = this.replicas.get(id);
if (!replica) return false;
this.replicas.delete(id);
this.emit(ReplicationEvent.ReplicaRemoved, replica);
// If primary was removed, trigger failover
if (replica.role === ReplicaRole.Primary && this.config.failoverPolicy === FailoverPolicy.Automatic) {
this.triggerFailover();
}
return true;
}
/** Get a replica by ID */
getReplica(id: ReplicaId): Replica | undefined {
return this.replicas.get(id);
}
/** Update replica status */
updateStatus(id: ReplicaId, status: ReplicaStatus): void {
const replica = this.replicas.get(id);
if (!replica) {
throw ReplicationError.replicaNotFound(id);
}
const previousStatus = replica.status;
replica.status = status;
replica.lastSeen = Date.now();
if (previousStatus !== status) {
this.emit(ReplicationEvent.ReplicaStatusChanged, {
replica,
previousStatus,
newStatus: status,
});
// Check for failover conditions
if (
replica.role === ReplicaRole.Primary &&
status === ReplicaStatus.Failed &&
this.config.failoverPolicy === FailoverPolicy.Automatic
) {
this.triggerFailover();
}
}
}
/** Update replica lag */
updateLag(id: ReplicaId, lag: number): void {
const replica = this.replicas.get(id);
if (replica) {
replica.lag = lag;
replica.lastSeen = Date.now();
}
}
/** Promote a secondary to primary */
promote(id: ReplicaId): void {
const replica = this.replicas.get(id);
if (!replica) {
throw ReplicationError.replicaNotFound(id);
}
if (replica.role === ReplicaRole.Primary) {
return; // Already primary
}
// Demote current primary
const currentPrimary = this.primary;
if (currentPrimary) {
currentPrimary.role = ReplicaRole.Secondary;
}
// Promote new primary
replica.role = ReplicaRole.Primary;
this.emit(ReplicationEvent.PrimaryChanged, {
previousPrimary: currentPrimary?.id,
newPrimary: id,
});
}
/** Trigger automatic failover */
private triggerFailover(): void {
this.emit(ReplicationEvent.FailoverStarted, {});
// Find the best candidate (lowest lag, active secondary)
const candidates = this.secondaries
.filter((r) => r.status === ReplicaStatus.Active)
.sort((a, b) => a.lag - b.lag);
if (candidates.length === 0) {
this.emit(ReplicationEvent.Error, ReplicationError.noPrimary());
return;
}
const newPrimary = candidates[0];
this.promote(newPrimary.id);
this.emit(ReplicationEvent.FailoverCompleted, { newPrimary: newPrimary.id });
}
/** Start heartbeat monitoring */
startHeartbeat(): void {
if (this.heartbeatTimer) return;
this.heartbeatTimer = setInterval(() => {
const now = Date.now();
for (const replica of this.replicas.values()) {
if (now - replica.lastSeen > this.config.healthCheckTimeout) {
if (replica.status === ReplicaStatus.Active) {
this.updateStatus(replica.id, ReplicaStatus.Offline);
}
}
}
}, this.config.heartbeatInterval);
}
/** Stop heartbeat monitoring */
stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/** Get all replicas */
getAllReplicas(): Replica[] {
return Array.from(this.replicas.values());
}
/** Get replica set stats */
getStats(): {
total: number;
active: number;
syncing: number;
offline: number;
failed: number;
hasQuorum: boolean;
} {
const replicas = Array.from(this.replicas.values());
return {
total: replicas.length,
active: replicas.filter((r) => r.status === ReplicaStatus.Active).length,
syncing: replicas.filter((r) => r.status === ReplicaStatus.Syncing).length,
offline: replicas.filter((r) => r.status === ReplicaStatus.Offline).length,
failed: replicas.filter((r) => r.status === ReplicaStatus.Failed).length,
hasQuorum: this.hasQuorum,
};
}
}

View file

@ -0,0 +1,260 @@
/**
* Sync Manager Implementation
* Manages data synchronization across replicas
*/
import EventEmitter from 'eventemitter3';
import {
type ReplicaId,
type SyncConfig,
type LogEntry,
type ChangeEvent,
SyncMode,
ReplicationError,
ReplicationEvent,
ChangeOperation,
} from './types.js';
import { VectorClock, type ConflictResolver, LastWriteWins } from './vector-clock.js';
import type { ReplicaSet } from './replica-set.js';
/** Default sync configuration */
const DEFAULT_SYNC_CONFIG: SyncConfig = {
mode: SyncMode.Asynchronous,
batchSize: 100,
maxLag: 5000,
};
/** Replication log for tracking changes */
export class ReplicationLog<T = unknown> {
private entries: LogEntry<T>[] = [];
private sequence = 0;
private readonly replicaId: ReplicaId;
private vectorClock: VectorClock;
constructor(replicaId: ReplicaId) {
this.replicaId = replicaId;
this.vectorClock = new VectorClock();
}
/** Get the current sequence number */
get currentSequence(): number {
return this.sequence;
}
/** Get the current vector clock */
get clock(): VectorClock {
return this.vectorClock.clone();
}
/** Append an entry to the log */
append(data: T): LogEntry<T> {
this.sequence++;
this.vectorClock.increment(this.replicaId);
const entry: LogEntry<T> = {
id: `${this.replicaId}-${this.sequence}`,
sequence: this.sequence,
data,
timestamp: Date.now(),
vectorClock: this.vectorClock.getValue(),
};
this.entries.push(entry);
return entry;
}
/** Get entries since a sequence number */
getEntriesSince(sequence: number, limit?: number): LogEntry<T>[] {
const filtered = this.entries.filter((e) => e.sequence > sequence);
return limit ? filtered.slice(0, limit) : filtered;
}
/** Get entry by ID */
getEntry(id: string): LogEntry<T> | undefined {
return this.entries.find((e) => e.id === id);
}
/** Get all entries */
getAllEntries(): LogEntry<T>[] {
return [...this.entries];
}
/** Apply entries from another replica */
applyEntries(entries: LogEntry<T>[]): void {
for (const entry of entries) {
const entryClock = new VectorClock(entry.vectorClock);
this.vectorClock.merge(entryClock);
}
// Note: In a real implementation, entries would be merged properly
}
/** Clear the log */
clear(): void {
this.entries = [];
this.sequence = 0;
this.vectorClock = new VectorClock();
}
}
/** Manages synchronization across replicas */
export class SyncManager<T = unknown> extends EventEmitter {
private readonly replicaSet: ReplicaSet;
private readonly log: ReplicationLog<T>;
private config: SyncConfig;
private conflictResolver: ConflictResolver<T>;
private pendingChanges: ChangeEvent<T>[] = [];
private syncTimer: ReturnType<typeof setInterval> | null = null;
constructor(
replicaSet: ReplicaSet,
log: ReplicationLog<T>,
config?: Partial<SyncConfig>,
) {
super();
this.replicaSet = replicaSet;
this.log = log;
this.config = { ...DEFAULT_SYNC_CONFIG, ...config };
// Default to timestamp-based resolution
this.conflictResolver = new LastWriteWins() as unknown as ConflictResolver<T>;
}
/** Set sync mode */
setSyncMode(mode: SyncMode, minReplicas?: number): void {
this.config.mode = mode;
if (minReplicas !== undefined) {
this.config.minReplicas = minReplicas;
}
}
/** Set custom conflict resolver */
setConflictResolver(resolver: ConflictResolver<T>): void {
this.conflictResolver = resolver;
}
/** Record a change for replication */
async recordChange(
key: string,
operation: ChangeOperation,
value?: T,
previousValue?: T,
): Promise<void> {
const primary = this.replicaSet.primary;
if (!primary) {
throw ReplicationError.noPrimary();
}
const entry = this.log.append({ key, operation, value, previousValue } as unknown as T);
const change: ChangeEvent<T> = {
id: entry.id,
operation,
key,
value,
previousValue,
timestamp: entry.timestamp,
sourceReplica: primary.id,
vectorClock: entry.vectorClock,
};
this.emit(ReplicationEvent.ChangeReceived, change);
// Handle based on sync mode
switch (this.config.mode) {
case SyncMode.Synchronous:
await this.syncAll(change);
break;
case SyncMode.SemiSync:
await this.syncMinimum(change);
break;
case SyncMode.Asynchronous:
this.pendingChanges.push(change);
break;
}
}
/** Sync a change to all replicas */
private async syncAll(change: ChangeEvent<T>): Promise<void> {
const secondaries = this.replicaSet.secondaries;
if (secondaries.length === 0) return;
this.emit(ReplicationEvent.SyncStarted, { replicas: secondaries.map((r) => r.id) });
// In a real implementation, this would send to all replicas
// For now, we just emit the completion event
this.emit(ReplicationEvent.SyncCompleted, { change, replicas: secondaries.map((r) => r.id) });
}
/** Sync to minimum number of replicas (semi-sync) */
private async syncMinimum(change: ChangeEvent<T>): Promise<void> {
const minReplicas = this.config.minReplicas ?? 1;
const secondaries = this.replicaSet.secondaries;
if (secondaries.length < minReplicas) {
throw ReplicationError.quorumNotMet(minReplicas, secondaries.length);
}
// Sync to minimum number of replicas
const targetReplicas = secondaries.slice(0, minReplicas);
this.emit(ReplicationEvent.SyncStarted, { replicas: targetReplicas.map((r) => r.id) });
// In a real implementation, this would wait for acknowledgments
this.emit(ReplicationEvent.SyncCompleted, { change, replicas: targetReplicas.map((r) => r.id) });
}
/** Start background sync for async mode */
startBackgroundSync(interval: number = 1000): void {
if (this.syncTimer) return;
this.syncTimer = setInterval(async () => {
if (this.pendingChanges.length > 0) {
const batch = this.pendingChanges.splice(0, this.config.batchSize);
for (const change of batch) {
await this.syncAll(change);
}
}
}, interval);
}
/** Stop background sync */
stopBackgroundSync(): void {
if (this.syncTimer) {
clearInterval(this.syncTimer);
this.syncTimer = null;
}
}
/** Resolve a conflict between local and remote values */
resolveConflict(
local: T,
remote: T,
localClock: VectorClock,
remoteClock: VectorClock,
): T {
// Check for causal relationship
if (localClock.happensBefore(remoteClock)) {
return remote; // Remote is newer
} else if (localClock.happensAfter(remoteClock)) {
return local; // Local is newer
}
// Concurrent - need conflict resolution
this.emit(ReplicationEvent.ConflictDetected, { local, remote });
const resolved = this.conflictResolver.resolve(local, remote, localClock, remoteClock);
this.emit(ReplicationEvent.ConflictResolved, { local, remote, resolved });
return resolved;
}
/** Get sync statistics */
getStats(): {
pendingChanges: number;
lastSequence: number;
syncMode: SyncMode;
} {
return {
pendingChanges: this.pendingChanges.length,
lastSequence: this.log.currentSequence,
syncMode: this.config.mode,
};
}
}

View file

@ -0,0 +1,205 @@
/**
* Replication Types
* Data replication and synchronization types
*/
import type EventEmitter from 'eventemitter3';
/** Unique identifier for a replica */
export type ReplicaId = string;
/** Logical timestamp for ordering events */
export type LogicalClock = number;
/** Role of a replica in the set */
export enum ReplicaRole {
Primary = 'primary',
Secondary = 'secondary',
Arbiter = 'arbiter',
}
/** Status of a replica */
export enum ReplicaStatus {
Active = 'active',
Syncing = 'syncing',
Offline = 'offline',
Failed = 'failed',
}
/** Synchronization mode */
export enum SyncMode {
/** All replicas must confirm before commit */
Synchronous = 'synchronous',
/** Commit immediately, replicate in background */
Asynchronous = 'asynchronous',
/** Wait for minimum number of replicas */
SemiSync = 'semi-sync',
}
/** Health status of a replica */
export enum HealthStatus {
Healthy = 'healthy',
Degraded = 'degraded',
Unhealthy = 'unhealthy',
Unknown = 'unknown',
}
/** Replica information */
export interface Replica {
id: ReplicaId;
address: string;
role: ReplicaRole;
status: ReplicaStatus;
lastSeen: number;
lag: number; // Replication lag in milliseconds
}
/** Change operation type */
export enum ChangeOperation {
Insert = 'insert',
Update = 'update',
Delete = 'delete',
}
/** Change event for CDC */
export interface ChangeEvent<T = unknown> {
/** Unique event ID */
id: string;
/** Operation type */
operation: ChangeOperation;
/** Affected key/path */
key: string;
/** New value (for insert/update) */
value?: T;
/** Previous value (for update/delete) */
previousValue?: T;
/** Timestamp of the change */
timestamp: number;
/** Source replica */
sourceReplica: ReplicaId;
/** Vector clock for ordering */
vectorClock: VectorClockValue;
}
/** Vector clock entry for a single node */
export type VectorClockValue = Map<ReplicaId, LogicalClock>;
/** Log entry for replication */
export interface LogEntry<T = unknown> {
/** Unique entry ID */
id: string;
/** Sequence number */
sequence: number;
/** Operation data */
data: T;
/** Timestamp */
timestamp: number;
/** Vector clock */
vectorClock: VectorClockValue;
}
/** Failover policy */
export enum FailoverPolicy {
/** Automatic failover with quorum */
Automatic = 'automatic',
/** Manual intervention required */
Manual = 'manual',
/** Priority-based failover */
Priority = 'priority',
}
/** Replication error types */
export class ReplicationError extends Error {
constructor(
message: string,
public readonly code: ReplicationErrorCode,
) {
super(message);
this.name = 'ReplicationError';
}
static replicaNotFound(id: string): ReplicationError {
return new ReplicationError(`Replica not found: ${id}`, ReplicationErrorCode.ReplicaNotFound);
}
static noPrimary(): ReplicationError {
return new ReplicationError('No primary replica available', ReplicationErrorCode.NoPrimary);
}
static timeout(operation: string): ReplicationError {
return new ReplicationError(`Replication timeout: ${operation}`, ReplicationErrorCode.Timeout);
}
static quorumNotMet(needed: number, available: number): ReplicationError {
return new ReplicationError(
`Quorum not met: needed ${needed}, got ${available}`,
ReplicationErrorCode.QuorumNotMet,
);
}
static splitBrain(): ReplicationError {
return new ReplicationError('Split-brain detected', ReplicationErrorCode.SplitBrain);
}
static conflictResolution(reason: string): ReplicationError {
return new ReplicationError(
`Conflict resolution failed: ${reason}`,
ReplicationErrorCode.ConflictResolution,
);
}
}
export enum ReplicationErrorCode {
ReplicaNotFound = 'REPLICA_NOT_FOUND',
NoPrimary = 'NO_PRIMARY',
Timeout = 'TIMEOUT',
SyncFailed = 'SYNC_FAILED',
ConflictResolution = 'CONFLICT_RESOLUTION',
FailoverFailed = 'FAILOVER_FAILED',
Network = 'NETWORK',
QuorumNotMet = 'QUORUM_NOT_MET',
SplitBrain = 'SPLIT_BRAIN',
InvalidState = 'INVALID_STATE',
}
/** Events emitted by replication components */
export enum ReplicationEvent {
ReplicaAdded = 'replicaAdded',
ReplicaRemoved = 'replicaRemoved',
ReplicaStatusChanged = 'replicaStatusChanged',
PrimaryChanged = 'primaryChanged',
ChangeReceived = 'changeReceived',
SyncStarted = 'syncStarted',
SyncCompleted = 'syncCompleted',
ConflictDetected = 'conflictDetected',
ConflictResolved = 'conflictResolved',
FailoverStarted = 'failoverStarted',
FailoverCompleted = 'failoverCompleted',
Error = 'error',
}
/** Configuration for replica set */
export interface ReplicaSetConfig {
/** Cluster name */
name: string;
/** Minimum replicas for quorum */
minQuorum: number;
/** Heartbeat interval in milliseconds */
heartbeatInterval: number;
/** Timeout for health checks in milliseconds */
healthCheckTimeout: number;
/** Failover policy */
failoverPolicy: FailoverPolicy;
}
/** Configuration for sync manager */
export interface SyncConfig {
/** Sync mode */
mode: SyncMode;
/** Minimum replicas for semi-sync */
minReplicas?: number;
/** Batch size for streaming changes */
batchSize: number;
/** Maximum lag before triggering catchup */
maxLag: number;
}

View file

@ -0,0 +1,148 @@
/**
* Vector Clock Implementation
* For conflict detection and resolution in distributed systems
*/
import type { ReplicaId, LogicalClock, VectorClockValue } from './types.js';
/** Comparison result between vector clocks */
export enum VectorClockComparison {
/** First happens before second */
Before = 'before',
/** First happens after second */
After = 'after',
/** Clocks are concurrent (no causal relationship) */
Concurrent = 'concurrent',
/** Clocks are equal */
Equal = 'equal',
}
/** Vector clock for tracking causality in distributed systems */
export class VectorClock {
private clock: Map<ReplicaId, LogicalClock>;
constructor(initial?: VectorClockValue | Map<ReplicaId, LogicalClock>) {
this.clock = new Map(initial);
}
/** Get the clock value for a replica */
get(replicaId: ReplicaId): LogicalClock {
return this.clock.get(replicaId) ?? 0;
}
/** Increment the clock for a replica */
increment(replicaId: ReplicaId): void {
const current = this.get(replicaId);
this.clock.set(replicaId, current + 1);
}
/** Update with a received clock (merge) */
merge(other: VectorClock): void {
for (const [replicaId, otherTime] of other.clock) {
const myTime = this.get(replicaId);
this.clock.set(replicaId, Math.max(myTime, otherTime));
}
}
/** Create a copy of this clock */
clone(): VectorClock {
return new VectorClock(new Map(this.clock));
}
/** Get the clock value as a Map */
getValue(): VectorClockValue {
return new Map(this.clock);
}
/** Compare two vector clocks */
compare(other: VectorClock): VectorClockComparison {
let isLess = false;
let isGreater = false;
// Get all unique replica IDs
const allReplicas = new Set([...this.clock.keys(), ...other.clock.keys()]);
for (const replicaId of allReplicas) {
const myTime = this.get(replicaId);
const otherTime = other.get(replicaId);
if (myTime < otherTime) {
isLess = true;
} else if (myTime > otherTime) {
isGreater = true;
}
}
if (isLess && isGreater) {
return VectorClockComparison.Concurrent;
} else if (isLess) {
return VectorClockComparison.Before;
} else if (isGreater) {
return VectorClockComparison.After;
} else {
return VectorClockComparison.Equal;
}
}
/** Check if this clock happens before another */
happensBefore(other: VectorClock): boolean {
return this.compare(other) === VectorClockComparison.Before;
}
/** Check if this clock happens after another */
happensAfter(other: VectorClock): boolean {
return this.compare(other) === VectorClockComparison.After;
}
/** Check if clocks are concurrent (no causal relationship) */
isConcurrent(other: VectorClock): boolean {
return this.compare(other) === VectorClockComparison.Concurrent;
}
/** Serialize to JSON */
toJSON(): Record<string, number> {
const obj: Record<string, number> = {};
for (const [key, value] of this.clock) {
obj[key] = value;
}
return obj;
}
/** Create from JSON */
static fromJSON(json: Record<string, number>): VectorClock {
const clock = new VectorClock();
for (const [key, value] of Object.entries(json)) {
clock.clock.set(key, value);
}
return clock;
}
/** Create a new vector clock with a single entry */
static single(replicaId: ReplicaId, time: LogicalClock = 1): VectorClock {
const clock = new VectorClock();
clock.clock.set(replicaId, time);
return clock;
}
}
/** Conflict resolver interface */
export interface ConflictResolver<T> {
/** Resolve a conflict between two values */
resolve(local: T, remote: T, localClock: VectorClock, remoteClock: VectorClock): T;
}
/** Last-write-wins conflict resolver */
export class LastWriteWins<T extends { timestamp: number }> implements ConflictResolver<T> {
resolve(local: T, remote: T): T {
return local.timestamp >= remote.timestamp ? local : remote;
}
}
/** Custom merge function conflict resolver */
export class MergeFunction<T> implements ConflictResolver<T> {
constructor(private mergeFn: (local: T, remote: T) => T) {}
resolve(local: T, remote: T): T {
return this.mergeFn(local, remote);
}
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View file

@ -0,0 +1,64 @@
{
"name": "@ruvector/scipix",
"version": "0.1.0",
"description": "OCR client for scientific documents - extract LaTeX, MathML from equations, research papers, and technical diagrams",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"test": "node --test test/*.test.js",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/node": "^20.19.30",
"typescript": "^5.9.3"
},
"keywords": [
"ocr",
"latex",
"mathml",
"scientific-computing",
"image-recognition",
"math-ocr",
"equation-recognition",
"document-processing",
"ruvector",
"pdf-extraction"
],
"author": "rUv Team <team@ruv.io>",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/ruvnet/ruvector.git",
"directory": "npm/packages/scipix"
},
"homepage": "https://github.com/ruvnet/ruvector/tree/main/examples/scipix",
"bugs": {
"url": "https://github.com/ruvnet/ruvector/issues"
},
"engines": {
"node": ">= 18"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"files": [
"dist",
"README.md"
]
}

View file

@ -0,0 +1,272 @@
/**
* SciPix OCR Client
* Client for interacting with SciPix OCR API
*/
import { readFile } from 'node:fs/promises';
import { extname } from 'node:path';
import {
type SciPixConfig,
type OCROptions,
type OCRResult,
type BatchOCRRequest,
type BatchOCRResult,
type HealthStatus,
SciPixError,
SciPixErrorCode,
OutputFormat,
ImageType,
} from './types.js';
/** Default configuration */
const DEFAULT_CONFIG: Required<Omit<SciPixConfig, 'apiKey'>> = {
baseUrl: 'http://localhost:8080',
timeout: 30000,
maxRetries: 3,
defaultOptions: {
formats: [OutputFormat.LaTeX, OutputFormat.Text],
detectEquations: true,
preprocess: true,
},
};
/** SciPix OCR Client */
export class SciPixClient {
private config: Required<Omit<SciPixConfig, 'apiKey'>> & { apiKey?: string };
constructor(config?: SciPixConfig) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Perform OCR on an image
* @param image - Image data as Buffer, base64 string, or file path
* @param options - OCR options
*/
async ocr(image: Buffer | string, options?: OCROptions): Promise<OCRResult> {
const imageData = await this.prepareImage(image);
const mergedOptions = { ...this.config.defaultOptions, ...options };
const response = await this.request('/api/v1/ocr', {
method: 'POST',
body: JSON.stringify({
image: imageData.base64,
imageType: imageData.type,
options: mergedOptions,
}),
});
return response as OCRResult;
}
/**
* Perform OCR on a file
* @param filePath - Path to the image file
* @param options - OCR options
*/
async ocrFile(filePath: string, options?: OCROptions): Promise<OCRResult> {
const buffer = await readFile(filePath);
return this.ocr(buffer, options);
}
/**
* Perform batch OCR on multiple images
* @param request - Batch OCR request
*/
async batchOcr(request: BatchOCRRequest): Promise<BatchOCRResult> {
const response = await this.request('/api/v1/ocr/batch', {
method: 'POST',
body: JSON.stringify(request),
});
return response as BatchOCRResult;
}
/**
* Extract LaTeX from an equation image
* @param image - Image data
*/
async extractLatex(image: Buffer | string): Promise<string> {
const result = await this.ocr(image, {
formats: [OutputFormat.LaTeX],
detectEquations: true,
});
return result.latex ?? result.text;
}
/**
* Extract MathML from an equation image
* @param image - Image data
*/
async extractMathML(image: Buffer | string): Promise<string> {
const result = await this.ocr(image, {
formats: [OutputFormat.MathML],
detectEquations: true,
});
return result.mathml ?? '';
}
/**
* Check API health status
*/
async health(): Promise<HealthStatus> {
const response = await this.request('/api/v1/health', {
method: 'GET',
});
return response as HealthStatus;
}
/**
* Prepare image for API request
*/
private async prepareImage(
image: Buffer | string,
): Promise<{ base64: string; type: ImageType }> {
let buffer: Buffer;
let type: ImageType = ImageType.PNG;
if (Buffer.isBuffer(image)) {
buffer = image;
type = this.detectImageType(buffer);
} else if (image.startsWith('data:')) {
// Base64 data URL
const match = image.match(/^data:image\/(\w+);base64,(.+)$/);
if (!match) {
throw SciPixError.invalidImage('Invalid data URL format');
}
type = this.parseImageType(match[1]);
return { base64: match[2], type };
} else if (image.startsWith('/') || image.includes(':\\')) {
// File path
buffer = await readFile(image);
type = this.getTypeFromExtension(extname(image));
} else {
// Assume base64 string
return { base64: image, type: ImageType.PNG };
}
return {
base64: buffer.toString('base64'),
type,
};
}
/**
* Detect image type from buffer magic bytes
*/
private detectImageType(buffer: Buffer): ImageType {
if (buffer[0] === 0x89 && buffer[1] === 0x50) return ImageType.PNG;
if (buffer[0] === 0xff && buffer[1] === 0xd8) return ImageType.JPEG;
if (buffer[0] === 0x52 && buffer[1] === 0x49) return ImageType.WebP;
if (buffer[0] === 0x25 && buffer[1] === 0x50) return ImageType.PDF;
if (buffer[0] === 0x49 && buffer[1] === 0x49) return ImageType.TIFF;
if (buffer[0] === 0x4d && buffer[1] === 0x4d) return ImageType.TIFF;
if (buffer[0] === 0x42 && buffer[1] === 0x4d) return ImageType.BMP;
return ImageType.PNG; // Default
}
/**
* Parse image type from MIME type
*/
private parseImageType(mimeType: string): ImageType {
switch (mimeType.toLowerCase()) {
case 'png':
return ImageType.PNG;
case 'jpeg':
case 'jpg':
return ImageType.JPEG;
case 'webp':
return ImageType.WebP;
case 'pdf':
return ImageType.PDF;
case 'tiff':
case 'tif':
return ImageType.TIFF;
case 'bmp':
return ImageType.BMP;
default:
return ImageType.PNG;
}
}
/**
* Get image type from file extension
*/
private getTypeFromExtension(ext: string): ImageType {
return this.parseImageType(ext.slice(1));
}
/**
* Make HTTP request to API
*/
private async request(
path: string,
options: RequestInit,
retries = 0,
): Promise<unknown> {
const url = `${this.config.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.config.apiKey) {
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const response = await fetch(url, {
...options,
headers: { ...headers, ...options.headers },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = await response.text();
if (response.status === 401) {
throw new SciPixError('Unauthorized', SciPixErrorCode.Unauthorized, 401);
}
if (response.status === 429) {
throw new SciPixError('Rate limited', SciPixErrorCode.RateLimited, 429);
}
throw SciPixError.serverError(error, response.status);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof SciPixError) {
throw error;
}
if ((error as Error).name === 'AbortError') {
throw SciPixError.timeout();
}
// Retry on network errors
if (retries < this.config.maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (retries + 1)));
return this.request(path, options, retries + 1);
}
throw SciPixError.networkError((error as Error).message);
}
}
}
/**
* Create a SciPix client with default configuration
*/
export function createClient(config?: SciPixConfig): SciPixClient {
return new SciPixClient(config);
}

View file

@ -0,0 +1,64 @@
/**
* @ruvector/scipix - OCR Client for Scientific Documents
*
* A TypeScript client for the SciPix OCR API, enabling extraction of
* LaTeX, MathML, and text from scientific images, equations, and documents.
*
* @example
* ```typescript
* import { SciPixClient, OutputFormat } from '@ruvector/scipix';
*
* // Create client
* const client = new SciPixClient({
* baseUrl: 'http://localhost:8080',
* apiKey: 'your-api-key',
* });
*
* // OCR an image file
* const result = await client.ocrFile('./equation.png', {
* formats: [OutputFormat.LaTeX, OutputFormat.MathML],
* detectEquations: true,
* });
*
* console.log('LaTeX:', result.latex);
* console.log('Confidence:', result.confidence);
*
* // Quick LaTeX extraction
* const latex = await client.extractLatex('./math.png');
* console.log('Extracted LaTeX:', latex);
*
* // Batch processing
* const batchResult = await client.batchOcr({
* images: [
* { source: 'base64...', id: 'eq1' },
* { source: 'base64...', id: 'eq2' },
* ],
* defaultOptions: { formats: [OutputFormat.LaTeX] },
* });
*
* console.log(`Processed ${batchResult.successful}/${batchResult.totalImages} images`);
* ```
*
* @packageDocumentation
*/
// Types
export {
OutputFormat,
ImageType,
ConfidenceLevel,
ContentType,
BoundingBox,
OCRRegion,
OCRResult,
OCROptions,
BatchOCRRequest,
BatchOCRResult,
SciPixConfig,
HealthStatus,
SciPixError,
SciPixErrorCode,
} from './types.js';
// Client
export { SciPixClient, createClient } from './client.js';

View file

@ -0,0 +1,230 @@
/**
* SciPix OCR Types
* Types for scientific document OCR and equation recognition
*/
/** Supported output formats for OCR results */
export enum OutputFormat {
/** LaTeX mathematical notation */
LaTeX = 'latex',
/** MathML markup language */
MathML = 'mathml',
/** ASCII math notation */
AsciiMath = 'asciimath',
/** Plain text */
Text = 'text',
/** Structured JSON with metadata */
JSON = 'json',
}
/** Supported image input types */
export enum ImageType {
PNG = 'png',
JPEG = 'jpeg',
WebP = 'webp',
PDF = 'pdf',
TIFF = 'tiff',
BMP = 'bmp',
}
/** OCR confidence level */
export enum ConfidenceLevel {
High = 'high',
Medium = 'medium',
Low = 'low',
}
/** Type of content detected in the image */
export enum ContentType {
/** Mathematical equation */
Equation = 'equation',
/** Text content */
Text = 'text',
/** Table structure */
Table = 'table',
/** Diagram or chart */
Diagram = 'diagram',
/** Mixed content */
Mixed = 'mixed',
}
/** Bounding box for detected regions */
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
/** Single OCR result region */
export interface OCRRegion {
/** Unique identifier for this region */
id: string;
/** Bounding box of the detected region */
bbox: BoundingBox;
/** Type of content detected */
contentType: ContentType;
/** Raw text content */
text: string;
/** LaTeX representation (if applicable) */
latex?: string;
/** MathML representation (if applicable) */
mathml?: string;
/** Confidence score (0-1) */
confidence: number;
/** Confidence level */
confidenceLevel: ConfidenceLevel;
}
/** Complete OCR result */
export interface OCRResult {
/** Unique result identifier */
id: string;
/** Original image dimensions */
imageDimensions: {
width: number;
height: number;
};
/** All detected regions */
regions: OCRRegion[];
/** Combined text output */
text: string;
/** Combined LaTeX output (if requested) */
latex?: string;
/** Combined MathML output (if requested) */
mathml?: string;
/** Processing time in milliseconds */
processingTime: number;
/** Model version used */
modelVersion: string;
/** Overall confidence */
confidence: number;
/** Metadata */
metadata: {
imageType: ImageType;
hasEquations: boolean;
hasTables: boolean;
hasDiagrams: boolean;
pageCount?: number;
};
}
/** OCR request options */
export interface OCROptions {
/** Desired output formats */
formats?: OutputFormat[];
/** Language hints for OCR */
languages?: string[];
/** Enable equation detection */
detectEquations?: boolean;
/** Enable table detection */
detectTables?: boolean;
/** Enable diagram detection */
detectDiagrams?: boolean;
/** Minimum confidence threshold (0-1) */
minConfidence?: number;
/** Enable preprocessing (deskew, denoise) */
preprocess?: boolean;
/** DPI hint for scanned documents */
dpi?: number;
/** Specific pages to process (for PDFs) */
pages?: number[];
}
/** Batch OCR request */
export interface BatchOCRRequest {
/** Array of image URLs or base64 data */
images: Array<{
/** URL or base64 data */
source: string;
/** Optional identifier */
id?: string;
/** Per-image options */
options?: OCROptions;
}>;
/** Default options for all images */
defaultOptions?: OCROptions;
}
/** Batch OCR result */
export interface BatchOCRResult {
/** Total images processed */
totalImages: number;
/** Successful results */
successful: number;
/** Failed results */
failed: number;
/** Individual results */
results: Array<{
id: string;
success: boolean;
result?: OCRResult;
error?: string;
}>;
/** Total processing time */
totalProcessingTime: number;
}
/** SciPix client configuration */
export interface SciPixConfig {
/** API base URL */
baseUrl?: string;
/** API key for authentication */
apiKey?: string;
/** Request timeout in milliseconds */
timeout?: number;
/** Maximum retries for failed requests */
maxRetries?: number;
/** Default OCR options */
defaultOptions?: OCROptions;
}
/** Health check response */
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
version: string;
models: {
name: string;
loaded: boolean;
version: string;
}[];
uptime: number;
}
/** Error types */
export class SciPixError extends Error {
constructor(
message: string,
public readonly code: SciPixErrorCode,
public readonly statusCode?: number,
) {
super(message);
this.name = 'SciPixError';
}
static networkError(message: string): SciPixError {
return new SciPixError(message, SciPixErrorCode.Network);
}
static serverError(message: string, statusCode: number): SciPixError {
return new SciPixError(message, SciPixErrorCode.Server, statusCode);
}
static invalidImage(message: string): SciPixError {
return new SciPixError(message, SciPixErrorCode.InvalidImage);
}
static timeout(): SciPixError {
return new SciPixError('Request timed out', SciPixErrorCode.Timeout);
}
}
export enum SciPixErrorCode {
Network = 'NETWORK',
Server = 'SERVER',
InvalidImage = 'INVALID_IMAGE',
Timeout = 'TIMEOUT',
InvalidConfig = 'INVALID_CONFIG',
Unauthorized = 'UNAUTHORIZED',
RateLimited = 'RATE_LIMITED',
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}