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:
@@ -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
451
src/lib/stores/api-sync.ts
Normal 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();
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user