10-04-2026
This commit is contained in:
parent
70a7776da5
commit
761b1156c1
50 changed files with 11997 additions and 150 deletions
253
frontend/_src/services/syncService.js
Normal file
253
frontend/_src/services/syncService.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue