169 lines
4.8 KiB
TypeScript
169 lines
4.8 KiB
TypeScript
import type { QueryClient } from "@tanstack/react-query";
|
|
import type { MemoDetail } from "@edgeever/shared";
|
|
|
|
export type WorkspaceRefreshMode = "background" | "manual";
|
|
export type WorkspaceMemoView = "notebook" | "trash";
|
|
|
|
export const DEFERRED_MEMO_SYNC_DELAY_MS = 30_000;
|
|
export const BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
export const BACKGROUND_WORKSPACE_REFRESH_LEASE_MS = BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS * 2;
|
|
export const BACKGROUND_WORKSPACE_REFRESH_COALESCE_MS = 1_000;
|
|
|
|
type RefreshLease = {
|
|
ownerId: string;
|
|
expiresAt: number;
|
|
};
|
|
|
|
const parseRefreshLease = (value: string | null): RefreshLease | null => {
|
|
if (!value) return null;
|
|
try {
|
|
const lease = JSON.parse(value) as Partial<RefreshLease>;
|
|
return typeof lease.ownerId === "string" && Number.isFinite(lease.expiresAt)
|
|
? { ownerId: lease.ownerId, expiresAt: Number(lease.expiresAt) }
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const claimBackgroundRefreshLease = ({
|
|
storage,
|
|
key,
|
|
ownerId,
|
|
now = Date.now(),
|
|
leaseMs = BACKGROUND_WORKSPACE_REFRESH_LEASE_MS,
|
|
}: {
|
|
storage: Pick<Storage, "getItem" | "setItem">;
|
|
key: string;
|
|
ownerId: string;
|
|
now?: number;
|
|
leaseMs?: number;
|
|
}) => {
|
|
const current = parseRefreshLease(storage.getItem(key));
|
|
if (current && current.ownerId !== ownerId && current.expiresAt > now) return false;
|
|
|
|
storage.setItem(key, JSON.stringify({ ownerId, expiresAt: now + leaseMs }));
|
|
return parseRefreshLease(storage.getItem(key))?.ownerId === ownerId;
|
|
};
|
|
|
|
export const releaseBackgroundRefreshLease = ({
|
|
storage,
|
|
key,
|
|
ownerId,
|
|
}: {
|
|
storage: Pick<Storage, "getItem" | "removeItem">;
|
|
key: string;
|
|
ownerId: string;
|
|
}) => {
|
|
if (parseRefreshLease(storage.getItem(key))?.ownerId === ownerId) storage.removeItem(key);
|
|
};
|
|
|
|
export const createRefreshSingleFlight = <T>({
|
|
refresh,
|
|
now = Date.now,
|
|
coalesceMs = BACKGROUND_WORKSPACE_REFRESH_COALESCE_MS,
|
|
}: {
|
|
refresh: () => Promise<T>;
|
|
now?: () => number;
|
|
coalesceMs?: number;
|
|
}) => {
|
|
let active: Promise<T> | null = null;
|
|
let lastStartedAt = Number.NEGATIVE_INFINITY;
|
|
|
|
return () => {
|
|
if (active) return active;
|
|
const startedAt = now();
|
|
if (startedAt - lastStartedAt < coalesceMs) return Promise.resolve(null);
|
|
lastStartedAt = startedAt;
|
|
active = refresh().finally(() => {
|
|
active = null;
|
|
});
|
|
return active;
|
|
};
|
|
};
|
|
|
|
export const shouldNavigateHomeWhenOpeningMemo = (view: WorkspaceMemoView) => view !== "trash";
|
|
|
|
export const resolveSyncedMemoId = (
|
|
memoIdMappings: ReadonlyMap<string, string>,
|
|
memoId: string | null,
|
|
) => memoId ? memoIdMappings.get(memoId) ?? memoId : null;
|
|
|
|
type MemoDetailQueryData = { memo: MemoDetail };
|
|
|
|
/**
|
|
* Keep the active editor backed by cached detail while sync replaces a local
|
|
* memo id with its durable server id. Without this handoff, React Query briefly
|
|
* observes an empty query under the new key and swaps the editor for its loading
|
|
* state until the desktop sidecar returns the same memo again.
|
|
*/
|
|
export const preserveRemappedMemoDetailQueries = (
|
|
queryClient: Pick<QueryClient, "getQueryData" | "setQueryData">,
|
|
memoIdMappings: ReadonlyMap<string, string>,
|
|
) => {
|
|
for (const [temporaryId, remoteId] of memoIdMappings) {
|
|
if (temporaryId === remoteId) continue;
|
|
|
|
for (const view of ["notebook", "trash"] as const) {
|
|
const remoteKey = ["memo", remoteId, view] as const;
|
|
if (queryClient.getQueryData<MemoDetailQueryData>(remoteKey)?.memo) continue;
|
|
|
|
const temporary = queryClient.getQueryData<MemoDetailQueryData>([
|
|
"memo",
|
|
temporaryId,
|
|
view,
|
|
]);
|
|
if (!temporary?.memo) continue;
|
|
|
|
queryClient.setQueryData<MemoDetailQueryData>(remoteKey, {
|
|
...temporary,
|
|
memo: { ...temporary.memo, id: remoteId },
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
export const resolveCreatedMemoSelection = (
|
|
currentMemoId: string | null,
|
|
pendingMemoId: string | null,
|
|
temporaryMemoId: string,
|
|
remoteMemoId: string,
|
|
) => currentMemoId === temporaryMemoId || pendingMemoId === temporaryMemoId
|
|
? remoteMemoId
|
|
: currentMemoId;
|
|
|
|
export type WorkspaceRefreshResult = {
|
|
changed: number;
|
|
skipped: boolean;
|
|
};
|
|
|
|
export const refreshWorkspaceData = async ({
|
|
mode,
|
|
hasPendingLocalChanges,
|
|
pushLocalChanges,
|
|
pullRemoteChanges,
|
|
invalidateWorkspaceQueries,
|
|
}: {
|
|
mode: WorkspaceRefreshMode;
|
|
hasPendingLocalChanges: boolean;
|
|
pushLocalChanges: () => Promise<void>;
|
|
pullRemoteChanges: () => Promise<{ changed: number }>;
|
|
invalidateWorkspaceQueries: () => Promise<void>;
|
|
}): Promise<WorkspaceRefreshResult> => {
|
|
if (mode === "background" && hasPendingLocalChanges) {
|
|
return { changed: 0, skipped: true };
|
|
}
|
|
|
|
if (mode === "manual") {
|
|
await pushLocalChanges();
|
|
}
|
|
|
|
const result = await pullRemoteChanges();
|
|
|
|
if (mode === "manual" || result.changed > 0) {
|
|
await invalidateWorkspaceQueries();
|
|
}
|
|
|
|
return { changed: result.changed, skipped: false };
|
|
};
|