diff --git a/web/package-lock.json b/web/package-lock.json index c587e2c..47aa5ea 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@bytelyst/feature-flag-client": "file:../../learning_ai_common_plat/packages/feature-flag-client", "@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth", "@bytelyst/subscription-client": "file:../../learning_ai_common_plat/packages/subscription-client", + "@bytelyst/sync": "file:../../learning_ai_common_plat/packages/sync", "@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client", "@serwist/next": "^9.5.6", "date-fns": "^4.1.0", @@ -94,6 +95,22 @@ "name": "@bytelyst/subscription-client", "version": "0.1.0" }, + "../../learning_ai_common_plat/packages/sync": { + "name": "@bytelyst/sync", + "version": "0.1.0", + "dependencies": { + "@bytelyst/api-client": "workspace:*", + "@bytelyst/telemetry-client": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "@bytelyst/api-client": "workspace:*" + } + }, "../../learning_ai_common_plat/packages/telemetry-client": { "name": "@bytelyst/telemetry-client", "version": "0.1.0" @@ -471,6 +488,10 @@ "resolved": "../../learning_ai_common_plat/packages/subscription-client", "link": true }, + "node_modules/@bytelyst/sync": { + "resolved": "../../learning_ai_common_plat/packages/sync", + "link": true + }, "node_modules/@bytelyst/telemetry-client": { "resolved": "../../learning_ai_common_plat/packages/telemetry-client", "link": true diff --git a/web/package.json b/web/package.json index 0b40f0c..3c3caaa 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@bytelyst/api-client": "file:../../learning_ai_common_plat/packages/api-client", + "@bytelyst/sync": "file:../../learning_ai_common_plat/packages/sync", "@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client", "@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth", "@bytelyst/diagnostics-client": "file:../../learning_ai_common_plat/packages/diagnostics-client", diff --git a/web/src/lib/platform-sync.ts b/web/src/lib/platform-sync.ts index 3aff003..65c2ac6 100644 --- a/web/src/lib/platform-sync.ts +++ b/web/src/lib/platform-sync.ts @@ -1,10 +1,21 @@ // ── Platform Sync Client ────────────────────────────────────── -// Cross-platform sync via platform-service REST API +// Cross-platform sync via @bytelyst/sync engine + platform-service REST API // Consumed by useSyncHook for React integration +// +// Migration note: offline queue, retry, deduplication, and connectivity +// are now delegated to @bytelyst/sync. DTO conversion and auth helpers +// remain ChronoMind-specific. import type { Timer } from './timer-engine'; import type { Routine, RoutineStep } from './routines'; -import { getAuthClient, PRODUCT_ID as _PRODUCT_ID } from './auth-api'; +import { getAuthClient, getBaseUrl, PRODUCT_ID as _PRODUCT_ID } from './auth-api'; +import { + createSyncEngine, + LocalStorageAdapter, + type SyncEngine as BLSyncEngine, + type SyncResult as BLSyncResult, +} from '@bytelyst/sync'; +import { createApiClient, type ApiClient } from '@bytelyst/api-client'; // ── DTOs ────────────────────────────────────────────────────── @@ -82,7 +93,7 @@ export interface OfflineQueueItem { enqueuedAt: number; } -// ── API Client (delegated to @bytelyst/auth-client) ────────── +// ── Storage Keys ────────────────────────────────────────────── const STORAGE_KEYS = { lastSync: 'chronomind-platform-last-sync', @@ -92,52 +103,42 @@ const STORAGE_KEYS = { export const PRODUCT_ID = _PRODUCT_ID; -export class SyncConflictError extends Error { - constructor(public serverData: unknown) { - super('Sync conflict — server has newer version'); +// ── @bytelyst/sync Engine (lazy singleton) ─────────────────── + +let _syncEngine: BLSyncEngine | null = null; +let _apiClient: ApiClient | null = null; + +function getApiClient(): ApiClient { + if (!_apiClient) { + _apiClient = createApiClient({ + baseUrl: getBaseUrl(), + getToken: () => getAuthClient().getAccessToken(), + }); } + return _apiClient; } -async function apiRequest( - path: string, - method: string, - body?: unknown -): Promise { - const client = getAuthClient(); - const token = client.getAccessToken(); - const baseUrl = process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app'; - const headers: Record = { - 'Content-Type': 'application/json', - 'x-request-id': crypto.randomUUID(), - 'x-product-id': PRODUCT_ID, - }; - if (token) headers['Authorization'] = `Bearer ${token}`; - - const res = await fetch(`${baseUrl}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); - - if (res.status === 409) { - const data = await res.json(); - throw new SyncConflictError(data); +export function getSyncEngine(): BLSyncEngine { + if (!_syncEngine) { + _syncEngine = createSyncEngine({ + productId: PRODUCT_ID, + entities: { + timers: { + endpoint: '/timers', + partitionKey: 'userId', + conflictStrategy: 'server-wins', + }, + routines: { + endpoint: '/routines', + partitionKey: 'userId', + conflictStrategy: 'server-wins', + }, + }, + storage: new LocalStorageAdapter('chronomind:sync:'), + apiClient: getApiClient(), + }); } - - // On 401, attempt a silent token refresh and retry once - if (res.status === 401 && !path.startsWith('/auth/')) { - const refreshed = await client.refreshAccessToken(); - if (refreshed) { - return apiRequest(path, method, body); - } - } - - if (!res.ok) { - throw new Error(`Sync API error: ${res.status} ${res.statusText}`); - } - - if (res.status === 204) return undefined as T; - return res.json(); + return _syncEngine; } // ── Public API ──────────────────────────────────────────────── @@ -186,65 +187,12 @@ export async function verifyEmail(token: string): Promise<{ message: string }> { return getAuthClient().verifyEmail(token); } -// ── Sync Operations ─────────────────────────────────────────── - -export async function pullDelta(since?: string): Promise { - const params = since ? `?since=${encodeURIComponent(since)}` : ''; - return apiRequest(`/timers/sync${params}`, 'GET'); -} - -export async function pushTimer(dto: SyncTimerDTO): Promise { - return apiRequest('/timers', 'POST', dto); -} - -export async function updateRemoteTimer( - id: string, - updates: Partial -): Promise { - return apiRequest(`/timers/${id}`, 'PUT', updates); -} - -export async function deleteRemoteTimer(id: string): Promise { - return apiRequest(`/timers/${id}`, 'DELETE'); -} - -export async function batchUpsert( - timers: SyncTimerDTO[] -): Promise { - return apiRequest('/timers/batch', 'POST', { timers }); -} - -// ── Routine Sync Operations ────────────────────────────────── - -export async function pullRoutineDelta(since?: string): Promise { - const params = since ? `?since=${encodeURIComponent(since)}` : ''; - return apiRequest(`/routines/sync${params}`, 'GET'); -} - -export async function pushRoutine(dto: SyncRoutineDTO): Promise { - return apiRequest('/routines', 'POST', dto); -} - -export async function updateRemoteRoutine( - id: string, - updates: Partial -): Promise { - return apiRequest(`/routines/${id}`, 'PUT', updates); -} - -export async function deleteRemoteRoutine(id: string): Promise { - return apiRequest(`/routines/${id}`, 'DELETE'); -} - -export async function batchUpsertRoutines( - routines: SyncRoutineDTO[] -): Promise { - return apiRequest('/routines/batch', 'POST', { routines }); -} - -// ── Offline Queue ───────────────────────────────────────────── +// ── Offline Queue (delegated to @bytelyst/sync engine) ─────── +// These functions maintain the same public API but now use the +// sync engine's queue internally for deduplication and retry. export function loadOfflineQueue(): OfflineQueueItem[] { + // Read from legacy localStorage key for backward compat if (typeof window === 'undefined') return []; try { const raw = localStorage.getItem(STORAGE_KEYS.offlineQueue); @@ -254,7 +202,7 @@ export function loadOfflineQueue(): OfflineQueueItem[] { } } -export function saveOfflineQueue(queue: OfflineQueueItem[]): void { +function saveOfflineQueue(queue: OfflineQueueItem[]): void { if (typeof window === 'undefined') return; localStorage.setItem(STORAGE_KEYS.offlineQueue, JSON.stringify(queue)); } @@ -263,6 +211,14 @@ export function enqueueChange( timer: Timer, action: 'create' | 'update' | 'delete' ): void { + const engine = getSyncEngine(); + const operation = action === 'create' ? 'create' : action === 'update' ? 'update' : 'delete'; + if (action === 'delete') { + void engine.delete('timers', timer.id); + } else { + void engine.push('timers', timerToDTO(timer), operation); + } + // Also maintain legacy queue for loadOfflineQueue() consumers const queue = loadOfflineQueue().filter((item) => item.id !== timer.id); queue.push({ id: timer.id, @@ -275,6 +231,8 @@ export function enqueueChange( } export function enqueueDeleteChange(timerId: string): void { + void getSyncEngine().delete('timers', timerId); + // Also maintain legacy queue const queue = loadOfflineQueue().filter((item) => item.id !== timerId); queue.push({ id: timerId, action: 'delete', entityType: 'timer', enqueuedAt: Date.now() }); saveOfflineQueue(queue); @@ -284,6 +242,14 @@ export function enqueueRoutineChange( routine: Routine, action: 'create' | 'update' | 'delete' ): void { + const engine = getSyncEngine(); + const operation = action === 'create' ? 'create' : action === 'update' ? 'update' : 'delete'; + if (action === 'delete') { + void engine.delete('routines', routine.id); + } else { + void engine.push('routines', routineToDTO(routine), operation); + } + // Also maintain legacy queue const queue = loadOfflineQueue().filter((item) => item.id !== routine.id); queue.push({ id: routine.id, @@ -296,6 +262,8 @@ export function enqueueRoutineChange( } export function enqueueRoutineDeleteChange(routineId: string): void { + void getSyncEngine().delete('routines', routineId); + // Also maintain legacy queue const queue = loadOfflineQueue().filter((item) => item.id !== routineId); queue.push({ id: routineId, action: 'delete', entityType: 'routine', enqueuedAt: Date.now() }); saveOfflineQueue(queue); @@ -308,6 +276,79 @@ function clearSyncedFromQueue(syncedIds: string[]): void { saveOfflineQueue(queue); } +// ── Sync Operations (direct API — used by fullSync below) ──── + +async function apiRequest( + path: string, + method: string, + body?: unknown +): Promise { + const client = getAuthClient(); + const token = client.getAccessToken(); + const baseUrl = getBaseUrl(); + const headers: Record = { + 'Content-Type': 'application/json', + 'x-request-id': crypto.randomUUID(), + 'x-product-id': PRODUCT_ID, + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (res.status === 409) { + const data = await res.json(); + throw new Error(`Sync conflict: ${JSON.stringify(data)}`); + } + + if (res.status === 401 && !path.startsWith('/auth/')) { + const refreshed = await client.refreshAccessToken(); + if (refreshed) { + return apiRequest(path, method, body); + } + } + + if (!res.ok) { + throw new Error(`Sync API error: ${res.status} ${res.statusText}`); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +async function pullDelta(since?: string): Promise { + const params = since ? `?since=${encodeURIComponent(since)}` : ''; + return apiRequest(`/timers/sync${params}`, 'GET'); +} + +async function batchUpsert( + timers: SyncTimerDTO[] +): Promise { + return apiRequest('/timers/batch', 'POST', { timers }); +} + +async function pullRoutineDelta(since?: string): Promise { + const params = since ? `?since=${encodeURIComponent(since)}` : ''; + return apiRequest(`/routines/sync${params}`, 'GET'); +} + +async function batchUpsertRoutines( + routines: SyncRoutineDTO[] +): Promise { + return apiRequest('/routines/batch', 'POST', { routines }); +} + +async function deleteRemoteTimer(id: string): Promise { + return apiRequest(`/timers/${id}`, 'DELETE'); +} + +async function deleteRemoteRoutine(id: string): Promise { + return apiRequest(`/routines/${id}`, 'DELETE'); +} + // ── Full Sync ───────────────────────────────────────────────── export interface SyncResult { @@ -379,7 +420,15 @@ export async function fullSync(): Promise { } } - // 3. Update last sync + // 3. Also flush the @bytelyst/sync engine queue (picks up items + // enqueued via the new code path) + try { + await getSyncEngine().flush(); + } catch { + // best-effort — legacy path already handled above + } + + // 4. Update last sync setLastSyncDate(new Date().toISOString()); return { pulled, pulledRoutines, conflicts };