thats-me/frontend/_src/services/syncService.js
2026-04-22 12:57:10 +02:00

253 lines
5.9 KiB
JavaScript

import { ref } from 'vue'
import { db } from 'src/db'
// API base URL — configured per environment
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
const isSyncing = ref(false)
const isOnline = ref(navigator.onLine)
const lastSyncAt = ref(null)
// Track online status
window.addEventListener('online', () => {
isOnline.value = true
processSyncQueue()
})
window.addEventListener('offline', () => {
isOnline.value = false
})
/**
* Get the stored OAuth access token.
*/
async function getToken() {
try {
const meta = await db.meta.get('accessToken')
return meta?.value || null
} catch {
return null
}
}
/**
* Store an OAuth access token.
*/
async function setToken(token) {
await db.meta.put({ key: 'accessToken', value: token })
}
/**
* Authenticated fetch wrapper.
*/
async function apiFetch(path, options = {}) {
const token = await getToken()
if (!token) throw new Error('Not authenticated')
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
})
if (response.status === 401) {
// Token expired — clear it
await db.meta.delete('accessToken')
throw new Error('Unauthorized')
}
return response
}
/**
* Process the outbound sync queue (FIFO).
* Called on app start, every 30s when online, and on reconnect.
*/
async function processSyncQueue() {
if (!isOnline.value || isSyncing.value) return
const token = await getToken()
if (!token) return
isSyncing.value = true
try {
const queue = await db.syncQueue.orderBy('queueId').toArray()
if (queue.length === 0) {
isSyncing.value = false
return
}
// Batch sync: send up to 100 mutations at once
const batch = queue.slice(0, 100)
const mutations = batch.map((item) => ({
action: item.action,
eventId: item.eventId,
payload: item.payload,
}))
const response = await apiFetch('/events/sync', {
method: 'POST',
body: JSON.stringify({ mutations }),
})
if (response.ok) {
const data = await response.json()
// Remove successfully processed items from queue
const processedIds = []
data.results.forEach((result, i) => {
if (result.status === 'ok') {
processedIds.push(batch[i].queueId)
}
})
if (processedIds.length > 0) {
await db.syncQueue.bulkDelete(processedIds)
}
// Update syncStatus on local events
for (const result of data.results) {
if (result.status === 'ok') {
const event = await db.events.get(result.eventId)
if (event && event.syncStatus !== 'local') {
await db.events.update(result.eventId, { syncStatus: 'synced' })
}
}
}
lastSyncAt.value = Date.now()
// If there are more items, process next batch
if (queue.length > 100) {
await processSyncQueue()
}
}
} catch (e) {
console.warn('Sync queue processing failed:', e)
} finally {
isSyncing.value = false
}
}
/**
* Pull remote changes since last sync cursor.
* Merges with local data using "last write wins" on updatedAt.
*/
async function pullRemoteChanges() {
if (!isOnline.value) return
const token = await getToken()
if (!token) return
try {
const lastSync = await db.meta.get('lastSyncCursor')
const since = lastSync?.value || null
let url = '/events?limit=200'
if (since) {
url += `&since=${since}`
}
const response = await apiFetch(url)
if (!response.ok) return
const data = await response.json()
const remoteEvents = data.data || []
for (const remote of remoteEvents) {
const local = await db.events.get(remote.id)
if (!local) {
// New event from server
await db.events.put({
id: remote.id,
title: remote.title,
date: remote.date,
emotion: remote.emotion,
customColor: remote.customColor,
gradientPreset: remote.gradientPreset,
image: remote.image,
note: remote.note,
syncStatus: 'synced',
createdAt: remote.createdAt,
updatedAt: remote.updatedAt,
})
} else if (remote.updatedAt > local.updatedAt && local.syncStatus === 'synced') {
// Remote is newer and local hasn't been modified — update
await db.events.update(remote.id, {
title: remote.title,
date: remote.date,
emotion: remote.emotion,
customColor: remote.customColor,
gradientPreset: remote.gradientPreset,
image: remote.image,
note: remote.note,
syncStatus: 'synced',
updatedAt: remote.updatedAt,
})
}
// If local is modified, skip — local changes will be pushed via sync queue
}
// Update sync cursor
await db.meta.put({ key: 'lastSyncCursor', value: new Date().toISOString() })
// Handle pagination (cursor-based)
if (data.next_cursor) {
// There are more pages — but for now we only pull one batch
// Future: iterate through pages
}
lastSyncAt.value = Date.now()
} catch (e) {
console.warn('Pull remote changes failed:', e)
}
}
/**
* Full sync: push local changes, then pull remote.
*/
async function fullSync() {
await processSyncQueue()
await pullRemoteChanges()
}
// Auto-sync interval (30s)
let syncInterval = null
function startAutoSync() {
if (syncInterval) return
syncInterval = setInterval(() => {
if (isOnline.value) {
fullSync()
}
}, 30000)
// Initial sync
fullSync()
}
function stopAutoSync() {
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
}
export {
isOnline,
isSyncing,
lastSyncAt,
getToken,
setToken,
apiFetch,
processSyncQueue,
pullRemoteChanges,
fullSync,
startAutoSync,
stopAutoSync,
}