253 lines
5.9 KiB
JavaScript
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,
|
|
}
|