125 lines
3.2 KiB
TypeScript
125 lines
3.2 KiB
TypeScript
export interface RealtimeRefreshScope {
|
|
projectId?: string;
|
|
projectIds?: string[];
|
|
deviceId?: string;
|
|
deviceIds?: string[];
|
|
conversationUpdatedNotes?: string[];
|
|
}
|
|
|
|
export interface RefreshThrottleState {
|
|
lastRefreshAt: number | null;
|
|
pending: boolean;
|
|
}
|
|
|
|
export type RefreshThrottleDecision =
|
|
| { type: "refresh_now" }
|
|
| { type: "schedule_refresh"; delayMs: number }
|
|
| { type: "skip" };
|
|
|
|
interface RealtimeEventPayload {
|
|
projectId?: string;
|
|
deviceId?: string;
|
|
note?: string;
|
|
}
|
|
|
|
function parseRealtimeEventPayload(eventData: unknown): RealtimeEventPayload | null {
|
|
if (typeof eventData !== "string" || !eventData.trim()) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(eventData) as RealtimeEventPayload;
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function shouldRefreshRealtimeEvent(input: {
|
|
eventType: string;
|
|
eventData?: unknown;
|
|
} & RealtimeRefreshScope) {
|
|
const payload = parseRealtimeEventPayload(input.eventData);
|
|
const projectScopeIds = new Set(
|
|
[input.projectId, ...(input.projectIds ?? [])]
|
|
.filter((value): value is string => Boolean(value?.trim()))
|
|
.map((value) => value.trim()),
|
|
);
|
|
const deviceScopeIds = new Set(
|
|
[input.deviceId, ...(input.deviceIds ?? [])]
|
|
.filter((value): value is string => Boolean(value?.trim()))
|
|
.map((value) => value.trim()),
|
|
);
|
|
let matchedScopedIdentifier = false;
|
|
|
|
if (projectScopeIds.size > 0) {
|
|
if (payload && typeof payload.projectId === "string" && payload.projectId.trim()) {
|
|
if (!projectScopeIds.has(payload.projectId.trim())) {
|
|
return false;
|
|
}
|
|
matchedScopedIdentifier = true;
|
|
}
|
|
}
|
|
|
|
if (deviceScopeIds.size > 0) {
|
|
if (payload && typeof payload.deviceId === "string" && payload.deviceId.trim()) {
|
|
if (!deviceScopeIds.has(payload.deviceId.trim())) {
|
|
return false;
|
|
}
|
|
matchedScopedIdentifier = true;
|
|
}
|
|
}
|
|
|
|
if ((projectScopeIds.size > 0 || deviceScopeIds.size > 0) && !matchedScopedIdentifier) {
|
|
return false;
|
|
}
|
|
|
|
if (input.eventType === "conversation.updated" && input.conversationUpdatedNotes?.length) {
|
|
if (!payload || typeof payload.note !== "string" || !payload.note.trim()) {
|
|
return false;
|
|
}
|
|
if (!input.conversationUpdatedNotes.includes(payload.note)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function createRefreshThrottleState(): RefreshThrottleState {
|
|
return {
|
|
lastRefreshAt: null,
|
|
pending: false,
|
|
};
|
|
}
|
|
|
|
export function planThrottledRefresh(
|
|
state: RefreshThrottleState,
|
|
now: number,
|
|
throttleMs: number,
|
|
): RefreshThrottleDecision {
|
|
if (state.pending) {
|
|
return { type: "skip" };
|
|
}
|
|
if (state.lastRefreshAt === null || now - state.lastRefreshAt >= throttleMs) {
|
|
state.lastRefreshAt = now;
|
|
return { type: "refresh_now" };
|
|
}
|
|
state.pending = true;
|
|
return {
|
|
type: "schedule_refresh",
|
|
delayMs: Math.max(0, throttleMs - (now - state.lastRefreshAt)),
|
|
};
|
|
}
|
|
|
|
export function markScheduledRefreshExecuted(state: RefreshThrottleState, now: number) {
|
|
state.lastRefreshAt = now;
|
|
state.pending = false;
|
|
}
|
|
|
|
export function cancelScheduledRefresh(state: RefreshThrottleState) {
|
|
state.pending = false;
|
|
}
|