Files
frontend/src/stores/postManager.ts

175 lines
4.8 KiB
TypeScript
Raw Normal View History

import { Post } from '../types';
import { postService } from '../services/postService';
import { CacheBus, CacheEvent } from './cacheBus';
interface PostListPayload {
key: string;
posts: Post[];
}
interface PostDetailPayload {
postId: string;
post: Post | null;
}
interface PostManagerSnapshot {
listKeys: string[];
detailKeys: string[];
}
type PostManagerEvent =
| CacheEvent<PostListPayload>
| CacheEvent<PostDetailPayload>
| CacheEvent<PostManagerSnapshot>
| CacheEvent<{ error: unknown; context: string }>;
const LIST_TTL = 30 * 1000;
const DETAIL_TTL = 60 * 1000;
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
private listCache = new Map<string, CacheEntry<Post[]>>();
private detailCache = new Map<string, CacheEntry<Post | null>>();
private pendingRequests = new Map<string, Promise<any>>();
protected getSnapshot(): PostManagerSnapshot {
return {
listKeys: [...this.listCache.keys()],
detailKeys: [...this.detailCache.keys()],
};
}
private isExpired(entry: CacheEntry<any>): boolean {
return Date.now() - entry.timestamp > entry.ttl;
}
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
if (pending) return pending;
const request = fetcher().finally(() => {
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, request);
return request;
}
private listKey(type: string, page: number, pageSize: number): string {
return `${type}:${page}:${pageSize}`;
}
async getPosts(
type = 'hot',
page = 1,
pageSize = 20,
forceRefresh = false
): Promise<Post[]> {
const key = this.listKey(type, page, pageSize);
const cached = this.listCache.get(key);
if (!forceRefresh && cached && !this.isExpired(cached)) {
return cached.data;
}
if (!forceRefresh && cached && this.isExpired(cached)) {
this.refreshPostsInBackground(key, type, page, pageSize);
return cached.data;
}
return this.dedupe(`posts:list:${key}`, async () => {
const response = await postService.getPosts(page, pageSize, type);
const posts = response.list || [];
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
this.notify({
type: 'list_updated',
payload: { key, posts },
timestamp: Date.now(),
});
return posts;
});
}
private refreshPostsInBackground(key: string, type: string, page: number, pageSize: number): void {
this.dedupe(`posts:list:bg:${key}`, async () => {
const response = await postService.getPosts(page, pageSize, type);
const posts = response.list || [];
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
this.notify({
type: 'list_updated',
payload: { key, posts },
timestamp: Date.now(),
});
return posts;
}).catch((error) => {
this.notify({
type: 'error',
payload: { error, context: 'refreshPostsInBackground' },
timestamp: Date.now(),
});
});
}
async getPostDetail(postId: string, forceRefresh = false): Promise<Post | null> {
const cached = this.detailCache.get(postId);
if (!forceRefresh && cached && !this.isExpired(cached)) {
return cached.data;
}
if (!forceRefresh && cached && this.isExpired(cached)) {
this.refreshPostDetailInBackground(postId);
return cached.data;
}
return this.dedupe(`posts:detail:${postId}`, async () => {
const post = await postService.getPost(postId);
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
this.notify({
type: 'detail_updated',
payload: { postId, post },
timestamp: Date.now(),
});
return post;
});
}
private refreshPostDetailInBackground(postId: string): void {
this.dedupe(`posts:detail:bg:${postId}`, async () => {
const post = await postService.getPost(postId);
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
this.notify({
type: 'detail_updated',
payload: { postId, post },
timestamp: Date.now(),
});
return post;
}).catch((error) => {
this.notify({
type: 'error',
payload: { error, context: 'refreshPostDetailInBackground' },
timestamp: Date.now(),
});
});
}
invalidate(postId?: string): void {
if (postId) {
this.detailCache.delete(postId);
return;
}
this.listCache.clear();
this.detailCache.clear();
}
clear(): void {
this.listCache.clear();
this.detailCache.clear();
this.pendingRequests.clear();
}
}
export const postManager = new PostManager();