feat: Phase 2 — data sync (presets + game progress)
Server: - GET/PUT /api/v1/sync/presets — upsert with last-write-wins - DELETE /api/v1/sync/presets/:id - GET/PUT /api/v1/sync/progress — game progress upsert Client: - syncService.js: offline-first sync layer - localStorage remains primary store - Pushes to server when logged in - Merges server data into local on sync - Auto-sync every 30s + on tab focus - AuthContext starts/stops sync on login/logout - Sync runs on session restore (refresh token) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { auth as authApi, users as usersApi, setAccessToken, setOnUnauthorized } from './api.js';
|
||||
import { startAutoSync, stopAutoSync } from './syncService.js';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
@@ -13,6 +14,7 @@ export function AuthProvider({ children }) {
|
||||
const [showAuth, setShowAuth] = useState(false);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
stopAutoSync();
|
||||
try { await authApi.logout(); } catch {}
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
@@ -30,6 +32,7 @@ export function AuthProvider({ children }) {
|
||||
try {
|
||||
const me = await usersApi.me();
|
||||
setUser(me);
|
||||
startAutoSync();
|
||||
} catch {}
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -41,6 +44,7 @@ export function AuthProvider({ children }) {
|
||||
setAccessToken(data.accessToken);
|
||||
setUser(data.user);
|
||||
setShowAuth(false);
|
||||
startAutoSync();
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
@@ -49,6 +53,7 @@ export function AuthProvider({ children }) {
|
||||
setAccessToken(data.accessToken);
|
||||
setUser(data.user);
|
||||
setShowAuth(false);
|
||||
startAutoSync();
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
|
||||
134
packages/client/src/services/syncService.js
Normal file
134
packages/client/src/services/syncService.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import { getAccessToken } from './api.js';
|
||||
|
||||
const API_BASE = '/api/v1/sync';
|
||||
const SYNC_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
let _syncTimer = null;
|
||||
|
||||
async function apiFetch(method, path, body = null) {
|
||||
const token = getAccessToken();
|
||||
if (!token) return null;
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ==================== Preset Sync ====================
|
||||
|
||||
export async function syncPresets() {
|
||||
if (!getAccessToken()) return;
|
||||
|
||||
try {
|
||||
// Get local presets
|
||||
const localRaw = localStorage.getItem('reaktor_presets');
|
||||
const localPresets = localRaw ? JSON.parse(localRaw) : [];
|
||||
|
||||
// Get server presets
|
||||
const serverData = await apiFetch('GET', '/presets');
|
||||
if (!serverData) return;
|
||||
|
||||
// Push local presets to server
|
||||
if (localPresets.length > 0) {
|
||||
await apiFetch('PUT', '/presets', {
|
||||
presets: localPresets.map(p => ({
|
||||
name: p.name,
|
||||
data: p.data,
|
||||
updatedAt: p.savedAt || new Date().toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Merge server presets into local (add any missing)
|
||||
const serverPresets = serverData.presets || [];
|
||||
const localNames = new Set(localPresets.map(p => p.name));
|
||||
let merged = [...localPresets];
|
||||
|
||||
for (const sp of serverPresets) {
|
||||
if (!localNames.has(sp.name)) {
|
||||
merged.push({
|
||||
name: sp.name,
|
||||
data: sp.data,
|
||||
savedAt: sp.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('reaktor_presets', JSON.stringify(merged));
|
||||
} catch (err) {
|
||||
console.warn('[sync] preset sync failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Game Progress Sync ====================
|
||||
|
||||
export async function syncProgress() {
|
||||
if (!getAccessToken()) return;
|
||||
|
||||
try {
|
||||
const localRaw = localStorage.getItem('synthquest-progress');
|
||||
const localProgress = localRaw ? JSON.parse(localRaw) : null;
|
||||
|
||||
// Get server progress
|
||||
const serverData = await apiFetch('GET', '/progress');
|
||||
|
||||
if (localProgress) {
|
||||
// Push local to server
|
||||
await apiFetch('PUT', '/progress', {
|
||||
data: localProgress,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// If server has progress and local doesn't, pull it
|
||||
if (serverData?.progress && !localProgress) {
|
||||
localStorage.setItem('synthquest-progress', JSON.stringify(serverData.progress));
|
||||
}
|
||||
|
||||
// Also sync hint data
|
||||
const hintsRaw = localStorage.getItem('synthquest-hints');
|
||||
// Hints are local-only for now (anti-cheat integrity)
|
||||
} catch (err) {
|
||||
console.warn('[sync] progress sync failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Auto Sync ====================
|
||||
|
||||
export function startAutoSync() {
|
||||
if (_syncTimer) return;
|
||||
|
||||
// Initial sync
|
||||
syncPresets();
|
||||
syncProgress();
|
||||
|
||||
// Periodic sync
|
||||
_syncTimer = setInterval(() => {
|
||||
syncPresets();
|
||||
syncProgress();
|
||||
}, SYNC_INTERVAL);
|
||||
|
||||
// Sync on tab focus
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
syncPresets();
|
||||
syncProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function stopAutoSync() {
|
||||
if (_syncTimer) {
|
||||
clearInterval(_syncTimer);
|
||||
_syncTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'url';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import syncRoutes from './routes/sync.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -39,6 +40,7 @@ await fastify.register(rateLimit, {
|
||||
await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/v1/users' });
|
||||
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||
await fastify.register(syncRoutes, { prefix: '/api/v1/sync' });
|
||||
|
||||
// Rate limit auth endpoints more aggressively
|
||||
fastify.addHook('onRoute', (routeOptions) => {
|
||||
|
||||
104
packages/server/src/routes/sync.js
Normal file
104
packages/server/src/routes/sync.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
|
||||
export default async function syncRoutes(fastify) {
|
||||
|
||||
// Get all user presets
|
||||
fastify.get('/presets', { preHandler: [authenticate] }, async (request) => {
|
||||
const presets = await db.select({
|
||||
id: schema.presets.id,
|
||||
name: schema.presets.name,
|
||||
data: schema.presets.data,
|
||||
isAutosave: schema.presets.isAutosave,
|
||||
updatedAt: schema.presets.updatedAt,
|
||||
}).from(schema.presets)
|
||||
.where(eq(schema.presets.userId, request.user.id))
|
||||
.orderBy(schema.presets.updatedAt);
|
||||
|
||||
return { presets };
|
||||
});
|
||||
|
||||
// Upsert presets (merge from client)
|
||||
fastify.put('/presets', { preHandler: [authenticate] }, async (request) => {
|
||||
const { presets } = request.body;
|
||||
if (!Array.isArray(presets)) return { error: 'presets must be array' };
|
||||
|
||||
const results = [];
|
||||
for (const p of presets) {
|
||||
if (!p.name || !p.data) continue;
|
||||
|
||||
if (p.id) {
|
||||
// Update existing
|
||||
const [existing] = await db.select().from(schema.presets)
|
||||
.where(eq(schema.presets.id, p.id)).limit(1);
|
||||
|
||||
if (existing && existing.userId === request.user.id) {
|
||||
// Only update if client is newer
|
||||
const clientTime = p.updatedAt ? new Date(p.updatedAt) : new Date();
|
||||
if (clientTime >= new Date(existing.updatedAt)) {
|
||||
const [updated] = await db.update(schema.presets)
|
||||
.set({ name: p.name, data: p.data, isAutosave: p.isAutosave || false, updatedAt: new Date() })
|
||||
.where(eq(schema.presets.id, p.id))
|
||||
.returning();
|
||||
results.push(updated);
|
||||
} else {
|
||||
results.push(existing); // Server is newer, keep it
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new
|
||||
const [inserted] = await db.insert(schema.presets).values({
|
||||
userId: request.user.id,
|
||||
name: p.name,
|
||||
data: p.data,
|
||||
isAutosave: p.isAutosave || false,
|
||||
}).returning();
|
||||
results.push(inserted);
|
||||
}
|
||||
|
||||
return { presets: results };
|
||||
});
|
||||
|
||||
// Delete a preset
|
||||
fastify.delete('/presets/:id', { preHandler: [authenticate] }, async (request, reply) => {
|
||||
await db.delete(schema.presets)
|
||||
.where(eq(schema.presets.id, request.params.id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Get game progress
|
||||
fastify.get('/progress', { preHandler: [authenticate] }, async (request) => {
|
||||
const [progress] = await db.select().from(schema.gameProgress)
|
||||
.where(eq(schema.gameProgress.userId, request.user.id)).limit(1);
|
||||
|
||||
return { progress: progress?.data || null, updatedAt: progress?.updatedAt || null };
|
||||
});
|
||||
|
||||
// Upsert game progress (last-write-wins)
|
||||
fastify.put('/progress', { preHandler: [authenticate] }, async (request) => {
|
||||
const { data, updatedAt } = request.body;
|
||||
if (!data) return { error: 'data required' };
|
||||
|
||||
const [existing] = await db.select().from(schema.gameProgress)
|
||||
.where(eq(schema.gameProgress.userId, request.user.id)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
const clientTime = updatedAt ? new Date(updatedAt) : new Date();
|
||||
if (clientTime >= new Date(existing.updatedAt)) {
|
||||
await db.update(schema.gameProgress)
|
||||
.set({ data, updatedAt: new Date() })
|
||||
.where(eq(schema.gameProgress.userId, request.user.id));
|
||||
}
|
||||
} else {
|
||||
await db.insert(schema.gameProgress).values({
|
||||
userId: request.user.id,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user