From c91528dd9fa700e5d26bf1af8a7ffe76bf977922 Mon Sep 17 00:00:00 2001 From: Rohan Saini Date: Thu, 28 May 2026 23:31:43 +0530 Subject: [PATCH] refactor(sync-engine): replace unsafe any type with generic parameter in SyncJob payload (#36) --- poc-sync-engine/src/SyncManager.ts | 75 +++++++++++++++++++++++ poc-sync-engine/src/SyncQueue.ts | 53 ++++++++++++++++ poc-sync-engine/tests/SyncManager.test.ts | 73 ++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 poc-sync-engine/src/SyncManager.ts create mode 100644 poc-sync-engine/src/SyncQueue.ts create mode 100644 poc-sync-engine/tests/SyncManager.test.ts diff --git a/poc-sync-engine/src/SyncManager.ts b/poc-sync-engine/src/SyncManager.ts new file mode 100644 index 0000000..6733237 --- /dev/null +++ b/poc-sync-engine/src/SyncManager.ts @@ -0,0 +1,75 @@ +import { SyncQueue, SyncJob } from './SyncQueue'; + +export class SyncManager { + private queue: SyncQueue; + private isOnline: boolean = false; + private isProcessing: boolean = false; + + constructor(queue: SyncQueue) { + 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 { + 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): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (Math.random() > 0.7) { + reject(new Error('Network Timeout')); + } else { + resolve(); + } + }, 200); + }); + } +} diff --git a/poc-sync-engine/src/SyncQueue.ts b/poc-sync-engine/src/SyncQueue.ts new file mode 100644 index 0000000..7f6b3bb --- /dev/null +++ b/poc-sync-engine/src/SyncQueue.ts @@ -0,0 +1,53 @@ +export interface SyncJob { + id: string; + url: string; + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + payload: T; + timestamp: number; + retryCount: number; +} + +export class SyncQueue { + private queue: SyncJob[] = []; + + constructor() { + this.loadFromStorage(); + } + + public enqueue(job: Omit, 'id' | 'timestamp' | 'retryCount'>): void { + const newJob: SyncJob = { + ...job, + id: Math.random().toString(36).substring(2, 9), + timestamp: Date.now(), + retryCount: 0, + }; + this.queue.push(newJob); + this.persistToStorage(); + } + + public peek(): SyncJob | undefined { + return this.queue[0]; + } + + public dequeue(): SyncJob | undefined { + const job = this.queue.shift(); + this.persistToStorage(); + return job; + } + + public requeue(job: SyncJob): void { + job.retryCount += 1; + this.persistToStorage(); + } + + public getLength(): number { + return this.queue.length; + } + + private persistToStorage() { + } + + private loadFromStorage() { + this.queue = []; + } +} diff --git a/poc-sync-engine/tests/SyncManager.test.ts b/poc-sync-engine/tests/SyncManager.test.ts new file mode 100644 index 0000000..8a6bc22 --- /dev/null +++ b/poc-sync-engine/tests/SyncManager.test.ts @@ -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(); + 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'); + } + }); +});