Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions poc-sync-engine/src/SyncManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { SyncQueue, SyncJob } from './SyncQueue';

export class SyncManager<T = unknown> {
private queue: SyncQueue<T>;
private isOnline: boolean = false;
private isProcessing: boolean = false;

constructor(queue: SyncQueue<T>) {
this.queue = queue;
}

public updateNetworkStatus(online: boolean) {
console.log(`[Network] Status changed to ${online ? 'ONLINE' : 'OFFLINE'}`);
this.isOnline = online;
if (this.isOnline) {
this.drainQueue();
}
}

public getQueueLength(): number {
return this.queue.getLength();
}

public async drainQueue(): Promise<void> {
if (this.isProcessing || !this.isOnline || this.queue.getLength() === 0) {
return;
}

this.isProcessing = true;

while (this.queue.getLength() > 0 && this.isOnline) {
const job = this.queue.peek();
if (!job) break;

try {
console.log(`[SyncManager] Attempting to sync job ${job.id} (${job.method} ${job.url})`);

await this.mockFrappeApiCall(job);

console.log(`[SyncManager] Successfully synced job ${job.id}`);
this.queue.dequeue();
} catch (error) {
console.error(`[SyncManager] Sync failed for job ${job.id}`);

if (!this.isOnline) {
break;
}

if (job.retryCount >= 5) {
console.error(`[SyncManager] Max retries reached for job ${job.id}. Dropping or moving to DLQ.`);
this.queue.dequeue();
} else {
this.queue.requeue(job);
const backoffTime = Math.pow(2, job.retryCount) * 1000;
console.log(`[SyncManager] Backing off for ${backoffTime}ms before next attempt`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
}
}
}

this.isProcessing = false;
}

private async mockFrappeApiCall(job: SyncJob<T>): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.7) {
reject(new Error('Network Timeout'));
} else {
resolve();
}
}, 200);
});
}
}
53 changes: 53 additions & 0 deletions poc-sync-engine/src/SyncQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export interface SyncJob<T = unknown> {
id: string;
url: string;
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
payload: T;
timestamp: number;
retryCount: number;
}

export class SyncQueue<T = unknown> {
private queue: SyncJob<T>[] = [];

constructor() {
this.loadFromStorage();
}

public enqueue(job: Omit<SyncJob<T>, 'id' | 'timestamp' | 'retryCount'>): void {
const newJob: SyncJob<T> = {
...job,
id: Math.random().toString(36).substring(2, 9),
timestamp: Date.now(),
retryCount: 0,
};
this.queue.push(newJob);
this.persistToStorage();
}

public peek(): SyncJob<T> | undefined {
return this.queue[0];
}

public dequeue(): SyncJob<T> | undefined {
const job = this.queue.shift();
this.persistToStorage();
return job;
}

public requeue(job: SyncJob<T>): void {
job.retryCount += 1;
this.persistToStorage();
}

public getLength(): number {
return this.queue.length;
}

private persistToStorage() {
}

private loadFromStorage() {
this.queue = [];
}
}
73 changes: 73 additions & 0 deletions poc-sync-engine/tests/SyncManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { SyncQueue } from '../src/SyncQueue';
import { SyncManager } from '../src/SyncManager';

describe('Offline-First Sync Engine', () => {
let queue: SyncQueue;
let manager: SyncManager;

beforeEach(() => {
queue = new SyncQueue();
manager = new SyncManager(queue);
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

test('should enqueue mutations successfully', () => {
queue.enqueue({
url: '/api/resource/LMS Assignment Submission',
method: 'POST',
payload: { answer: 'A', assignment_id: '123' },
});

expect(queue.getLength()).toBe(1);
expect(queue.peek()?.url).toBe('/api/resource/LMS Assignment Submission');
});

test('should not process queue when offline', async () => {
queue.enqueue({ url: '/api/resource/Course', method: 'POST', payload: {} });

manager.updateNetworkStatus(false);
await manager.drainQueue();

expect(queue.getLength()).toBe(1);
});

test('should drain queue when online', async () => {
(manager as any).mockFrappeApiCall = jest.fn().mockResolvedValue(undefined);

queue.enqueue({ url: '/api/resource/A', method: 'POST', payload: {} });
queue.enqueue({ url: '/api/resource/B', method: 'POST', payload: {} });

manager.updateNetworkStatus(true);

await new Promise(process.nextTick);
await new Promise(process.nextTick);

expect(queue.getLength()).toBe(0);
});

test('should support and preserve typed payloads', () => {
interface AssignmentPayload {
answer: string;
assignment_id: string;
}
const typedQueue = new SyncQueue<AssignmentPayload>();
typedQueue.enqueue({
url: '/api/resource/LMS Assignment Submission',
method: 'POST',
payload: { answer: 'B', assignment_id: '456' },
});

const job = typedQueue.peek();
expect(job).toBeDefined();
if (job) {
const payload: AssignmentPayload = job.payload;
expect(payload.answer).toBe('B');
expect(payload.assignment_id).toBe('456');
}
});
});