260 lines
7.4 KiB
TypeScript
260 lines
7.4 KiB
TypeScript
import axios from 'axios';
|
|
import type { UUID } from '@/types/uuid';
|
|
import { uuidv7 } from 'uuidv7';
|
|
import { db } from './dexie-db';
|
|
|
|
export type StoreName =
|
|
| 'categories'
|
|
| 'accounts'
|
|
| 'banks'
|
|
| 'automation_rules'
|
|
| 'transactions';
|
|
|
|
export interface IndexedDBRecord {
|
|
id: UUID;
|
|
user_id?: UUID | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface SyncOptions {
|
|
storeName: StoreName;
|
|
endpoint: string;
|
|
transformFromServer?: (data: Record<string, unknown>) => Record<string, unknown>;
|
|
transformToServer?: (data: Record<string, unknown>) => Record<string, unknown>;
|
|
}
|
|
|
|
export interface SyncResult {
|
|
success: boolean;
|
|
inserted: number;
|
|
updated: number;
|
|
deleted: number;
|
|
errors: string[];
|
|
}
|
|
|
|
export class SyncManager {
|
|
private syncInProgress = false;
|
|
private lastSyncKey: string;
|
|
|
|
constructor(private options: SyncOptions) {
|
|
this.lastSyncKey = `last_sync_${options.storeName}`;
|
|
}
|
|
|
|
async getLastSyncTime(): Promise<string | null> {
|
|
const metadata = await db.sync_metadata.get(this.lastSyncKey);
|
|
return metadata?.value || null;
|
|
}
|
|
|
|
async setLastSyncTime(timestamp: string): Promise<void> {
|
|
await db.sync_metadata.put({
|
|
key: this.lastSyncKey,
|
|
value: timestamp,
|
|
});
|
|
}
|
|
|
|
async sync(): Promise<SyncResult> {
|
|
if (this.syncInProgress) {
|
|
return {
|
|
success: false,
|
|
inserted: 0,
|
|
updated: 0,
|
|
deleted: 0,
|
|
errors: ['Sync already in progress'],
|
|
};
|
|
}
|
|
|
|
this.syncInProgress = true;
|
|
|
|
const result: SyncResult = {
|
|
success: true,
|
|
inserted: 0,
|
|
updated: 0,
|
|
deleted: 0,
|
|
errors: [],
|
|
};
|
|
|
|
try {
|
|
await this.syncFromServer(result);
|
|
await this.syncToServer(result);
|
|
|
|
await db.pending_changes
|
|
.where('store')
|
|
.equals(this.options.storeName)
|
|
.delete();
|
|
|
|
await this.setLastSyncTime(new Date().toISOString());
|
|
} catch (error) {
|
|
result.success = false;
|
|
result.errors.push(
|
|
error instanceof Error ? error.message : 'Unknown error',
|
|
);
|
|
} finally {
|
|
this.syncInProgress = false;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async syncFromServer(result: SyncResult): Promise<void> {
|
|
const lastSync = await this.getLastSyncTime();
|
|
|
|
const params: Record<string, string> = {};
|
|
if (lastSync) {
|
|
params.since = lastSync;
|
|
}
|
|
|
|
const response = await axios.get(this.options.endpoint, { params });
|
|
|
|
const serverData = response.data.data || response.data;
|
|
|
|
if (!Array.isArray(serverData)) {
|
|
throw new Error('Invalid server response format');
|
|
}
|
|
|
|
const table = db[this.options.storeName];
|
|
const localRecords = await table.toArray();
|
|
const localMap = new Map(localRecords.map((r) => [(r as IndexedDBRecord).id, r as IndexedDBRecord]));
|
|
|
|
for (const serverRecord of serverData) {
|
|
const transformed = this.options.transformFromServer
|
|
? this.options.transformFromServer(serverRecord)
|
|
: serverRecord;
|
|
|
|
const localRecord = localMap.get(transformed.id);
|
|
|
|
if (!localRecord) {
|
|
await table.put(transformed);
|
|
result.inserted++;
|
|
} else {
|
|
const serverDate = new Date(transformed.updated_at);
|
|
const localDate = new Date(localRecord.updated_at);
|
|
|
|
if (serverDate > localDate) {
|
|
await table.put(transformed);
|
|
result.updated++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async syncToServer(result: SyncResult): Promise<void> {
|
|
const pendingChanges = await db.pending_changes
|
|
.where('store')
|
|
.equals(this.options.storeName)
|
|
.toArray();
|
|
|
|
for (const change of pendingChanges) {
|
|
try {
|
|
const data = this.options.transformToServer
|
|
? this.options.transformToServer(change.data)
|
|
: change.data;
|
|
|
|
switch (change.operation) {
|
|
case 'create':
|
|
await axios.post(this.options.endpoint, data);
|
|
result.inserted++;
|
|
break;
|
|
case 'update':
|
|
await axios.patch(
|
|
`${this.options.endpoint}/${change.data.id}`,
|
|
data,
|
|
);
|
|
result.updated++;
|
|
break;
|
|
case 'delete':
|
|
await axios.delete(
|
|
`${this.options.endpoint}/${change.data.id}`,
|
|
);
|
|
result.deleted++;
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
result.errors.push(
|
|
`Failed to sync ${change.operation} for ${this.options.storeName}: ${error}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async createLocal<T extends IndexedDBRecord>(
|
|
data: Omit<T, 'id' | 'created_at' | 'updated_at'>,
|
|
): Promise<T> {
|
|
const timestamp = new Date().toISOString();
|
|
const id = uuidv7();
|
|
|
|
const record = {
|
|
...data,
|
|
id,
|
|
created_at: timestamp,
|
|
updated_at: timestamp,
|
|
} as T;
|
|
|
|
const table = db[this.options.storeName];
|
|
await table.put(record);
|
|
await db.pending_changes.add({
|
|
store: this.options.storeName,
|
|
operation: 'create',
|
|
data: record,
|
|
timestamp,
|
|
});
|
|
|
|
return record;
|
|
}
|
|
|
|
async updateLocal<T extends IndexedDBRecord>(
|
|
id: UUID,
|
|
data: Partial<T>,
|
|
): Promise<void> {
|
|
const table = db[this.options.storeName];
|
|
const existing = await table.get(id);
|
|
|
|
if (!existing) {
|
|
throw new Error(
|
|
`Record ${id} not found in ${this.options.storeName}`,
|
|
);
|
|
}
|
|
|
|
const timestamp = new Date().toISOString();
|
|
const updated = {
|
|
...existing,
|
|
...data,
|
|
updated_at: timestamp,
|
|
};
|
|
|
|
await table.put(updated);
|
|
await db.pending_changes.add({
|
|
store: this.options.storeName,
|
|
operation: 'update',
|
|
data: updated,
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
async deleteLocal(id: UUID): Promise<void> {
|
|
const timestamp = new Date().toISOString();
|
|
const table = db[this.options.storeName];
|
|
await table.delete(id);
|
|
await db.pending_changes.add({
|
|
store: this.options.storeName,
|
|
operation: 'delete',
|
|
data: { id },
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
async getAll<T extends IndexedDBRecord>(): Promise<T[]> {
|
|
const table = db[this.options.storeName];
|
|
return (await table.toArray()) as T[];
|
|
}
|
|
|
|
async getById<T extends IndexedDBRecord>(id: UUID): Promise<T | null> {
|
|
const table = db[this.options.storeName];
|
|
return ((await table.get(id)) as T) || null;
|
|
}
|
|
|
|
isSyncing(): boolean {
|
|
return this.syncInProgress;
|
|
}
|
|
}
|