thread-master/apps/web/src/lib/ownPostsSyncGate.ts

133 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { OwnPost } from "../domain/types";
import { KEYS } from "../data/mock/keys";
import { readJson, writeJson } from "./storage";
/** 本月成效:距離上次真 API sync 不足此間隔則不重打5 分鐘) */
export const OWN_POSTS_CURRENT_TTL_MS = 5 * 60 * 1000;
type AccountSyncMeta = {
/** 上次成功呼叫 sync API 的時間ms */
lastSyncMs: number;
/** 是否已為「過去月份缺資料」補抓過一次 */
pastOnceDone: boolean;
};
type MetaStore = Record<string, AccountSyncMeta>;
/** accountId → in-flight sync promisesingleflight */
const inflight = new Map<string, Promise<OwnPost[]>>();
function loadStore(): MetaStore {
return readJson(KEYS.ownPostsSyncMeta, {} as MetaStore);
}
function saveStore(store: MetaStore): void {
writeJson(KEYS.ownPostsSyncMeta, store);
}
export function getOwnPostsSyncMeta(accountId: string): AccountSyncMeta {
const row = loadStore()[accountId];
return {
lastSyncMs: row?.lastSyncMs ?? 0,
pastOnceDone: Boolean(row?.pastOnceDone),
};
}
function patchMeta(accountId: string, patch: Partial<AccountSyncMeta>): void {
const store = loadStore();
const prev = store[accountId] || { lastSyncMs: 0, pastOnceDone: false };
store[accountId] = { ...prev, ...patch };
saveStore(store);
}
export function isCurrentSyncFresh(accountId: string, now = Date.now()): boolean {
const { lastSyncMs } = getOwnPostsSyncMeta(accountId);
return lastSyncMs > 0 && now - lastSyncMs < OWN_POSTS_CURRENT_TTL_MS;
}
export function isPastOnceDone(accountId: string): boolean {
return getOwnPostsSyncMeta(accountId).pastOnceDone;
}
export type OwnPostsSyncReason =
/** 使用者手動同步:一定打 API */
| "manual"
/** 過去月份缺資料:整帳只補抓一次 */
| "past-once"
/** 本月定期刷新5 分鐘) */
| "current-ttl";
export type OwnPostsSyncResult = {
posts: OwnPost[];
/** 是否真的打了 sync APIfalse = 走 list 或共用 in-flight 已完成後的讀取) */
fetched: boolean;
/** 因 TTLpast-once 已完成而跳過 */
skipped: boolean;
reason: OwnPostsSyncReason;
};
type Ops = {
list: (accountId: string) => Promise<OwnPost[]>;
sync: (accountId: string) => Promise<OwnPost[]>;
};
/**
* 以 accountId 為 key 的 singleflight sync。
* - manual一定呼叫 sync並發共用同一個 promise
* - past-once若已 pastOnceDone 則只 list
* - current-ttl若 5 分鐘內已 sync 則只 list
*/
export async function syncOwnPostsGated(
accountId: string,
ops: Ops,
reason: OwnPostsSyncReason,
): Promise<OwnPostsSyncResult> {
if (!accountId) {
return { posts: [], fetched: false, skipped: true, reason };
}
const existing = inflight.get(accountId);
if (existing) {
const posts = await existing;
return { posts, fetched: true, skipped: false, reason };
}
if (reason === "past-once" && isPastOnceDone(accountId)) {
const posts = await ops.list(accountId);
return { posts, fetched: false, skipped: true, reason };
}
if (reason === "current-ttl" && isCurrentSyncFresh(accountId)) {
const posts = await ops.list(accountId);
return { posts, fetched: false, skipped: true, reason };
}
const promise = (async () => {
try {
const posts = await ops.sync(accountId);
const prev = getOwnPostsSyncMeta(accountId);
patchMeta(accountId, {
lastSyncMs: Date.now(),
// 手動或過去月份補抓 → 標記 past-once 完成TTL 刷新不改旗標
pastOnceDone:
prev.pastOnceDone || reason === "past-once" || reason === "manual",
});
return posts;
} finally {
inflight.delete(accountId);
}
})();
inflight.set(accountId, promise);
const posts = await promise;
return { posts, fetched: true, skipped: false, reason };
}
/** 過去月份(不含本月)是否仍全空——用來決定是否觸發 past-once */
export function needsPastMonthBackfill(
months: Array<{ key: string; posts: number }>,
currentMonthKey: string,
): boolean {
return months.some((m) => m.key !== currentMonthKey && m.posts <= 0);
}