feat:implement a sync-service

this new feature make sure that serverside actions are all in sync with
client. when a client is multiple times subscribed to the same server
side action it needs to be synced on all children. this implementation
gives access to one client synced status that will handle all api
actions in a queue.
This commit is contained in:
2025-10-29 00:09:01 +01:00
parent 3b3ebc2873
commit 6620cc6078
3 changed files with 569 additions and 51 deletions

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Heart } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
interface Props {
findId: string;
@@ -19,59 +21,66 @@
class: className = ''
}: Props = $props();
// Track the source of truth - server state
let serverIsLiked = $state(isLiked);
let serverLikeCount = $state(likeCount);
// Local state stores for this like button
const likeState = writable({
isLiked: isLiked,
likeCount: likeCount,
isLoading: false
});
// Track optimistic state during loading
let isLoading = $state(false);
let optimisticIsLiked = $state(isLiked);
let optimisticLikeCount = $state(likeCount);
let apiSync: any = null;
// Derived state for display
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked);
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount);
// Initialize API sync and subscribe to global state
onMount(async () => {
if (browser) {
try {
// Dynamically import the API sync
const module = await import('$lib/stores/api-sync');
apiSync = module.apiSync;
// Initialize the find's like state with props data
apiSync.setEntityState('find', findId, {
id: findId,
isLikedByUser: isLiked,
likeCount: likeCount,
// Minimal data needed for like functionality
title: '',
latitude: '',
longitude: '',
isPublic: true,
createdAt: new Date(),
userId: '',
username: '',
isFromFriend: false
});
// Subscribe to global state for this find
const globalLikeState = apiSync.subscribeFindLikes(findId);
globalLikeState.subscribe((state: any) => {
likeState.set({
isLiked: state.isLiked,
likeCount: state.likeCount,
isLoading: state.isLoading
});
});
} catch (error) {
console.error('Failed to initialize API sync:', error);
}
}
});
async function toggleLike() {
if (isLoading) return;
if (!apiSync || !browser) return;
// Set optimistic state
optimisticIsLiked = !serverIsLiked;
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
isLoading = true;
const currentState = likeState;
if (currentState && (currentState as any).isLoading) return;
try {
const method = optimisticIsLiked ? 'POST' : 'DELETE';
const response = await fetch(`/api/finds/${findId}/like`, {
method,
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const result = await response.json();
// Update server state with response
serverIsLiked = result.isLiked;
serverLikeCount = result.likeCount;
} catch (error: unknown) {
console.error('Error updating like:', error);
toast.error('Failed to update like. Please try again.');
} finally {
isLoading = false;
await apiSync.toggleLike(findId);
} catch (error) {
console.error('Failed to toggle like:', error);
}
}
// Update server state when props change (from parent component)
$effect(() => {
serverIsLiked = isLiked;
serverLikeCount = likeCount;
});
</script>
<Button
@@ -79,22 +88,22 @@
{size}
class="group gap-1.5 {className}"
onclick={toggleLike}
disabled={isLoading}
disabled={$likeState.isLoading}
>
<Heart
class="h-4 w-4 transition-all duration-200 {displayIsLiked
class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
? 'scale-110 fill-red-500 text-red-500'
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
? 'animate-pulse'
: ''}"
/>
{#if displayLikeCount > 0}
{#if $likeState.likeCount > 0}
<span
class="text-sm font-medium transition-colors {displayIsLiked
class="text-sm font-medium transition-colors {$likeState.isLiked
? 'text-red-500'
: 'text-gray-500 group-hover:text-red-400'}"
>
{displayLikeCount}
{$likeState.likeCount}
</span>
{/if}
</Button>

451
src/lib/stores/api-sync.ts Normal file
View File

@@ -0,0 +1,451 @@
import { writable, derived, type Readable, type Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
// Core types for the API sync system
export interface EntityState<T = unknown> {
data: T;
isLoading: boolean;
error: string | null;
lastUpdated: Date;
}
export interface QueuedOperation {
id: string;
entityType: string;
entityId: string;
operation: 'create' | 'update' | 'delete';
action?: string;
data?: unknown;
retry: number;
maxRetries: number;
timestamp: Date;
}
export interface APIResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
[key: string]: unknown;
}
// Specific entity state types
export interface FindLikeState {
isLiked: boolean;
likeCount: number;
isLoading: boolean;
error: string | null;
}
export interface FindState {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: boolean;
createdAt: Date;
userId: string;
username: string;
profilePictureUrl?: string;
media?: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
isLikedByUser: boolean;
likeCount: number;
isFromFriend: boolean;
}
// Generate unique operation IDs
function generateOperationId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Create operation key for deduplication
function createOperationKey(
entityType: string,
entityId: string,
operation: string,
action?: string
): string {
return `${entityType}:${entityId}:${operation}${action ? `:${action}` : ''}`;
}
class APISync {
// Entity stores - each entity type has its own store
private entityStores = new Map<string, Writable<Map<string, EntityState>>>();
// Operation queue for API calls
private operationQueue = new Map<string, QueuedOperation>();
private processingQueue = false;
// Cleanup tracking for memory management
private subscriptions = new Map<string, Set<() => void>>();
constructor() {
// Initialize core entity stores
this.initializeEntityStore('find');
this.initializeEntityStore('user');
this.initializeEntityStore('friendship');
// Start processing queue
this.startQueueProcessor();
}
private initializeEntityStore(entityType: string): void {
if (!this.entityStores.has(entityType)) {
this.entityStores.set(entityType, writable(new Map<string, EntityState>()));
}
}
private getEntityStore(entityType: string): Writable<Map<string, EntityState>> {
this.initializeEntityStore(entityType);
return this.entityStores.get(entityType)!;
}
/**
* Subscribe to a specific entity's state
*/
subscribe<T>(entityType: string, entityId: string): Readable<EntityState<T>> {
const store = this.getEntityStore(entityType);
return derived(store, ($entities) => {
const entity = $entities.get(entityId);
if (!entity) {
// Return default state if entity doesn't exist
return {
data: null as T,
isLoading: false,
error: null,
lastUpdated: new Date()
};
}
return entity as EntityState<T>;
});
}
/**
* Subscribe specifically to find like state
*/
subscribeFindLikes(findId: string): Readable<FindLikeState> {
const store = this.getEntityStore('find');
return derived(store, ($entities) => {
const entity = $entities.get(findId);
if (!entity || !entity.data) {
return {
isLiked: false,
likeCount: 0,
isLoading: false,
error: null
};
}
const findData = entity.data as FindState;
return {
isLiked: findData.isLikedByUser,
likeCount: findData.likeCount,
isLoading: entity.isLoading,
error: entity.error
};
});
}
/**
* Initialize entity state with server data
*/
setEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
newEntities.set(entityId, {
data,
isLoading,
error: null,
lastUpdated: new Date()
});
return newEntities;
});
}
/**
* Update entity loading state
*/
private setEntityLoading(entityType: string, entityId: string, isLoading: boolean): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(entityId);
if (existing) {
newEntities.set(entityId, {
...existing,
isLoading
});
}
return newEntities;
});
}
/**
* Update entity error state
*/
private setEntityError(entityType: string, entityId: string, error: string): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(entityId);
if (existing) {
newEntities.set(entityId, {
...existing,
isLoading: false,
error
});
}
return newEntities;
});
}
/**
* Queue an operation for processing
*/
async queueOperation(
entityType: string,
entityId: string,
operation: 'create' | 'update' | 'delete',
action?: string,
data?: Record<string, unknown>
): Promise<void> {
const operationKey = createOperationKey(entityType, entityId, operation, action);
// Check if same operation is already queued
if (this.operationQueue.has(operationKey)) {
console.log(`Operation ${operationKey} already queued, skipping duplicate`);
return;
}
const queuedOperation: QueuedOperation = {
id: generateOperationId(),
entityType,
entityId,
operation,
action,
data,
retry: 0,
maxRetries: 3,
timestamp: new Date()
};
this.operationQueue.set(operationKey, queuedOperation);
// Set entity to loading state
this.setEntityLoading(entityType, entityId, true);
// Process queue if not already processing
if (!this.processingQueue) {
this.processQueue();
}
}
/**
* Process the operation queue
*/
private async processQueue(): Promise<void> {
if (this.processingQueue || this.operationQueue.size === 0) {
return;
}
this.processingQueue = true;
const operations = Array.from(this.operationQueue.entries());
for (const [operationKey, operation] of operations) {
try {
await this.executeOperation(operation);
this.operationQueue.delete(operationKey);
} catch (error) {
console.error(`Operation ${operationKey} failed:`, error);
if (operation.retry < operation.maxRetries) {
operation.retry++;
console.log(
`Retrying operation ${operationKey} (attempt ${operation.retry}/${operation.maxRetries})`
);
} else {
console.error(`Operation ${operationKey} failed after ${operation.maxRetries} retries`);
this.operationQueue.delete(operationKey);
this.setEntityError(
operation.entityType,
operation.entityId,
'Operation failed after multiple retries'
);
toast.error('Failed to sync changes. Please try again.');
}
}
}
this.processingQueue = false;
// If more operations were added while processing, process again
if (this.operationQueue.size > 0) {
setTimeout(() => this.processQueue(), 1000); // Wait 1s before retry
}
}
/**
* Execute a specific operation
*/
private async executeOperation(operation: QueuedOperation): Promise<void> {
const { entityType, entityId, operation: op, action, data } = operation;
let response: Response;
if (entityType === 'find' && action === 'like') {
// Handle like operations
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
response = await fetch(`/api/finds/${entityId}/like`, {
method,
headers: {
'Content-Type': 'application/json'
}
});
} else {
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const result = await response.json();
// Update entity state with successful result
if (entityType === 'find' && action === 'like') {
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
}
}
/**
* Update find like state after successful API call
*/
private updateFindLikeState(findId: string, isLiked: boolean, likeCount: number): void {
const store = this.getEntityStore('find');
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
isLikedByUser: isLiked,
likeCount: likeCount
},
isLoading: false,
error: null,
lastUpdated: new Date()
});
}
return newEntities;
});
}
/**
* Start the queue processor
*/
private startQueueProcessor(): void {
// Process queue every 100ms
setInterval(() => {
if (this.operationQueue.size > 0 && !this.processingQueue) {
this.processQueue();
}
}, 100);
}
/**
* Toggle like for a find
*/
async toggleLike(findId: string): Promise<void> {
// Get current state for optimistic update
const store = this.getEntityStore('find');
let currentState: FindState | null = null;
const unsubscribe = store.subscribe(($entities) => {
const entity = $entities.get(findId);
if (entity?.data) {
currentState = entity.data as FindState;
}
});
unsubscribe();
if (!currentState) {
console.warn(`Cannot toggle like for find ${findId}: find state not found`);
return;
}
// Optimistic update
const findState = currentState as FindState;
const newIsLiked = !findState.isLikedByUser;
const newLikeCount = findState.likeCount + (newIsLiked ? 1 : -1);
// Update state optimistically
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
isLikedByUser: newIsLiked,
likeCount: newLikeCount
}
});
}
return newEntities;
});
// Queue the operation
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
}
/**
* Initialize find data from server
*/
initializeFindData(finds: FindState[]): void {
for (const find of finds) {
this.setEntityState('find', find.id, find);
}
}
/**
* Cleanup unused subscriptions (call this when components unmount)
*/
cleanup(entityType: string, entityId: string): void {
const key = `${entityType}:${entityId}`;
const subscriptions = this.subscriptions.get(key);
if (subscriptions) {
subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions.delete(key);
}
}
}
// Create singleton instance
export const apiSync = new APISync();

View File

@@ -7,6 +7,8 @@
import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location';
import { Button } from '$lib/components/button';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
// Server response type
interface ServerFind {
@@ -92,6 +94,62 @@
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
// Initialize API sync with server data on mount
onMount(async () => {
if (browser && data.finds && data.finds.length > 0) {
// Dynamically import the API sync to avoid SSR issues
const { apiSync } = await import('$lib/stores/api-sync');
// Define the FindState interface locally
interface FindState {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: boolean;
createdAt: Date;
userId: string;
username: string;
profilePictureUrl?: string;
media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
isLikedByUser: boolean;
likeCount: number;
isFromFriend: boolean;
}
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
id: serverFind.id,
title: serverFind.title,
description: serverFind.description,
latitude: serverFind.latitude,
longitude: serverFind.longitude,
locationName: serverFind.locationName,
category: serverFind.category,
isPublic: Boolean(serverFind.isPublic),
createdAt: new Date(serverFind.createdAt),
userId: serverFind.userId,
username: serverFind.username,
profilePictureUrl: serverFind.profilePictureUrl || undefined,
media: serverFind.media,
isLikedByUser: Boolean(serverFind.isLikedByUser),
likeCount: serverFind.likeCount || 0,
isFromFriend: Boolean(serverFind.isFromFriend)
}));
apiSync.initializeFindData(findStates);
}
});
// All finds - convert server format to component format
let allFinds = $derived(
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({