perf(platform): 创作页加载 - 合并用户数据与余额请求并按路由延迟加载

This commit is contained in:
ddCat
2026-08-14 18:35:17 +08:00
parent 2d3dd86fbe
commit 920cd92c16
9 changed files with 160 additions and 148 deletions

View File

@@ -299,6 +299,19 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"assets": assets})
})
r.GET("/user-data/snapshot", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
snapshot, err := svc.UserDataSnapshot(user.ID)
if err != nil {
failService(c, err)
return
}
ok(c, snapshot)
})
r.GET("/assets/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {

View File

@@ -29,6 +29,23 @@ type UserDataSummary struct {
UpdatedAt time.Time `json:"updatedAt"`
}
type UserDataSnapshot struct {
Assets []json.RawMessage `json:"assets"`
Projects []json.RawMessage `json:"projects"`
}
func (s *Service) UserDataSnapshot(userID string) (UserDataSnapshot, error) {
assets, err := s.UserAssets(userID)
if err != nil {
return UserDataSnapshot{}, err
}
projects, err := s.UserCanvasProjects(userID)
if err != nil {
return UserDataSnapshot{}, err
}
return UserDataSnapshot{Assets: assets, Projects: projects}, nil
}
func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) {
assets, err := s.repo.AssetSummaries(userID)
if err != nil {

View File

@@ -178,6 +178,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
</nav>
<div className="shrink-0 p-2">
<WorkspaceSidebarFooter
showAnnouncement={false}
expandedClassName="flex"
collapsedClassName="hidden"
accountClassName="flex-row gap-2 px-2"

View File

@@ -1,76 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { getWallet } from "@/services/api/wallet";
const WALLET_REFRESH_INTERVAL_MS = 30_000;
type WalletBalanceSnapshot = {
userId: string;
availableMicrocredits: number | null;
refreshing: boolean;
};
const walletBalanceQueryKey = (userId: string) => ["wallet-balance", userId] as const;
export function useWalletBalance(userId?: string, enabled = true) {
const activeUserId = enabled ? userId || "" : "";
const requestSequence = useRef(0);
const [snapshot, setSnapshot] = useState<WalletBalanceSnapshot>({ userId: "", availableMicrocredits: null, refreshing: false });
const refresh = useCallback(async () => {
const requestedUserId = activeUserId;
const sequence = ++requestSequence.current;
if (!requestedUserId) {
setSnapshot({ userId: "", availableMicrocredits: null, refreshing: false });
return;
}
setSnapshot((current) => ({
userId: requestedUserId,
availableMicrocredits: current.userId === requestedUserId ? current.availableMicrocredits : null,
refreshing: true,
}));
try {
const queryClient = useQueryClient();
const queryKey = walletBalanceQueryKey(activeUserId);
const query = useQuery({
queryKey,
enabled: Boolean(activeUserId),
queryFn: async () => {
const wallet = await getWallet(1, 1);
if (sequence !== requestSequence.current) return;
if (wallet.account.userId !== requestedUserId) throw new Error("积分账户与当前用户不一致");
setSnapshot({ userId: requestedUserId, availableMicrocredits: wallet.account.availableMicrocredits, refreshing: false });
} catch (error) {
if (sequence !== requestSequence.current) return;
console.warn("积分余额刷新失败", error);
setSnapshot((current) => ({
userId: requestedUserId,
availableMicrocredits: current.userId === requestedUserId ? current.availableMicrocredits : null,
refreshing: false,
}));
}
}, [activeUserId]);
if (wallet.account.userId !== activeUserId) throw new Error("积分账户与当前用户不一致");
return wallet.account.availableMicrocredits;
},
staleTime: 15_000,
refetchInterval: WALLET_REFRESH_INTERVAL_MS,
refetchOnWindowFocus: true,
});
useEffect(() => {
if (!activeUserId) {
requestSequence.current += 1;
setSnapshot({ userId: "", availableMicrocredits: null, refreshing: false });
return;
}
void refresh();
const timer = window.setInterval(() => void refresh(), WALLET_REFRESH_INTERVAL_MS);
const handleFocus = () => void refresh();
const handleVisibility = () => {
if (document.visibilityState === "visible") void refresh();
};
window.addEventListener("focus", handleFocus);
window.addEventListener("wallet:updated", handleFocus);
document.addEventListener("visibilitychange", handleVisibility);
if (!activeUserId) return;
const handleUpdated = () => void queryClient.invalidateQueries({ queryKey: walletBalanceQueryKey(activeUserId) });
window.addEventListener("wallet:updated", handleUpdated);
return () => {
requestSequence.current += 1;
window.clearInterval(timer);
window.removeEventListener("focus", handleFocus);
window.removeEventListener("wallet:updated", handleFocus);
document.removeEventListener("visibilitychange", handleVisibility);
window.removeEventListener("wallet:updated", handleUpdated);
};
}, [activeUserId, refresh]);
}, [activeUserId, queryClient]);
useEffect(() => {
if (query.error) console.warn("积分余额刷新失败", query.error);
}, [query.error]);
const snapshotMatchesUser = snapshot.userId === activeUserId;
return {
availableMicrocredits: snapshotMatchesUser ? snapshot.availableMicrocredits : null,
refreshing: snapshotMatchesUser ? snapshot.refreshing : Boolean(activeUserId),
refresh,
availableMicrocredits: query.data ?? null,
refreshing: query.isFetching,
refresh: query.refetch,
};
}

View File

@@ -1,47 +1,59 @@
import { lazy, Suspense, type ReactNode } from "react";
import { createBrowserRouter, Navigate, Outlet } from "react-router";
import { RequireAuth } from "@/components/auth/require-auth";
import { RequireFeature } from "@/components/auth/require-feature";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import UserLayout from "@/layouts/user-layout";
import AdminPage from "@/pages/admin";
import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsPage, EmailSettingsPage, FeatureAvailabilityPage } from "@/pages/admin/admin-route-pages";
import ChannelsPage from "@/pages/admin/channels/channels-page";
import LogsPage from "@/pages/admin/logs/logs-page";
import RedemptionCodesPage from "@/pages/admin/redemption-codes/redemption-codes-page";
import RuntimePolicySettingsPage from "@/pages/admin/settings/runtime-policy-settings-page";
import DrawingEngineSettingsPage from "@/pages/admin/settings/drawing-engine-settings-page";
import StorageSettingsPage from "@/pages/admin/settings/storage-settings-page";
import StoryboardPromptsPage from "@/pages/admin/storyboard-prompts/storyboard-prompts-page";
import UsersPage from "@/pages/admin/users/users-page";
import AssetsPage from "@/pages/assets";
import { AuthScene } from "@/pages/auth/auth-scene";
import LoginPage from "@/pages/auth/login";
import RegisterPage from "@/pages/auth/register";
import CanvasPage from "@/pages/canvas";
import CanvasProjectPage from "@/pages/canvas/project";
import SharedCanvasPage from "@/pages/canvas/shared";
import CreatePage from "@/pages/create";
import HomePage from "@/pages/home";
import NotFound from "@/pages/not-found";
import RouteErrorPage from "@/pages/route-error";
import SkillsPage from "@/pages/skills";
import TasksPage from "@/pages/tasks";
import WalletPage from "@/pages/wallet";
import ProjectsPage from "@/pages/projects";
import ProjectDetailPage from "@/pages/projects/detail";
import SettingsPage from "@/pages/settings";
import TestVoiceRecording from "@/pages/test-voice-recording";
const AdminPage = lazy(() => import("@/pages/admin"));
const AnalyticsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AnalyticsPage })));
const AnnouncementsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AnnouncementsPage })));
const CreditOperationsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.CreditOperationsPage })));
const AccessSettingsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AccessSettingsPage })));
const EmailSettingsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.EmailSettingsPage })));
const FeatureAvailabilityPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.FeatureAvailabilityPage })));
const ChannelsPage = lazy(() => import("@/pages/admin/channels/channels-page"));
const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page"));
const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page"));
const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runtime-policy-settings-page"));
const DrawingEngineSettingsPage = lazy(() => import("@/pages/admin/settings/drawing-engine-settings-page"));
const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-settings-page"));
const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page"));
const UsersPage = lazy(() => import("@/pages/admin/users/users-page"));
const AssetsPage = lazy(() => import("@/pages/assets"));
const LoginPage = lazy(() => import("@/pages/auth/login"));
const RegisterPage = lazy(() => import("@/pages/auth/register"));
const CanvasPage = lazy(() => import("@/pages/canvas"));
const CanvasProjectPage = lazy(() => import("@/pages/canvas/project"));
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
const CreatePage = lazy(() => import("@/pages/create"));
const HomePage = lazy(() => import("@/pages/home"));
const NotFound = lazy(() => import("@/pages/not-found"));
const SkillsPage = lazy(() => import("@/pages/skills"));
const TasksPage = lazy(() => import("@/pages/tasks"));
const WalletPage = lazy(() => import("@/pages/wallet"));
const ProjectsPage = lazy(() => import("@/pages/projects"));
const ProjectDetailPage = lazy(() => import("@/pages/projects/detail"));
const SettingsPage = lazy(() => import("@/pages/settings"));
const TestVoiceRecording = lazy(() => import("@/pages/test-voice-recording"));
function deferred(element: ReactNode) {
return <Suspense fallback={<FullScreenLoader label="正在打开页面" detail="仅加载当前工作区所需内容" />}>{element}</Suspense>;
}
export const router = createBrowserRouter([
{
element: <AuthScene />,
errorElement: <RouteErrorPage />,
children: [
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ path: "/login", element: deferred(<LoginPage />) },
{ path: "/register", element: deferred(<RegisterPage />) },
],
},
{ path: "/share/canvas/:token", element: <SharedCanvasPage />, errorElement: <RouteErrorPage /> },
{ path: "/share/canvas/:token", element: deferred(<SharedCanvasPage />), errorElement: <RouteErrorPage /> },
{
element: (
<UserLayout>
@@ -51,44 +63,44 @@ export const router = createBrowserRouter([
errorElement: <RouteErrorPage />,
children: [
{ path: "/", element: <Navigate to="/create" replace /> },
{ path: "/create", element: <RequireAuth><CreatePage /></RequireAuth> },
{ path: "/home", element: <HomePage /> },
{ path: "/tasks", element: <RequireAuth><RequireFeature feature="taskCenterEnabled"><TasksPage /></RequireFeature></RequireAuth> },
{ path: "/assets", element: <RequireAuth><AssetsPage /></RequireAuth> },
{ path: "/skills", element: <RequireAuth><SkillsPage /></RequireAuth> },
{ path: "/wallet", element: <RequireAuth><RequireFeature feature="creditsEnabled"><WalletPage /></RequireFeature></RequireAuth> },
{ path: "/settings", element: <RequireAuth><SettingsPage /></RequireAuth> },
{ path: "/test-voice-recording", element: <RequireAuth><TestVoiceRecording /></RequireAuth> },
{ path: "/projects", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectsPage /></RequireFeature></RequireAuth> },
{ path: "/projects/:projectId", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
{ path: "/projects/:projectId/:view", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
{ path: "/projects/:projectId/chapters/:chapterId", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
{ path: "/canvas", element: <RequireAuth><CanvasPage /></RequireAuth> },
{ path: "/canvas/:id", element: <RequireAuth><CanvasProjectPage /></RequireAuth> },
{ path: "/create", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> },
{ path: "/home", element: deferred(<HomePage />) },
{ path: "/tasks", element: <RequireAuth><RequireFeature feature="taskCenterEnabled">{deferred(<TasksPage />)}</RequireFeature></RequireAuth> },
{ path: "/assets", element: <RequireAuth>{deferred(<AssetsPage />)}</RequireAuth> },
{ path: "/skills", element: <RequireAuth>{deferred(<SkillsPage />)}</RequireAuth> },
{ path: "/wallet", element: <RequireAuth><RequireFeature feature="creditsEnabled">{deferred(<WalletPage />)}</RequireFeature></RequireAuth> },
{ path: "/settings", element: <RequireAuth>{deferred(<SettingsPage />)}</RequireAuth> },
{ path: "/test-voice-recording", element: <RequireAuth>{deferred(<TestVoiceRecording />)}</RequireAuth> },
{ path: "/projects", element: <RequireAuth><RequireFeature feature="shortDramaEnabled">{deferred(<ProjectsPage />)}</RequireFeature></RequireAuth> },
{ path: "/projects/:projectId", element: <RequireAuth><RequireFeature feature="shortDramaEnabled">{deferred(<ProjectDetailPage />)}</RequireFeature></RequireAuth> },
{ path: "/projects/:projectId/:view", element: <RequireAuth><RequireFeature feature="shortDramaEnabled">{deferred(<ProjectDetailPage />)}</RequireFeature></RequireAuth> },
{ path: "/projects/:projectId/chapters/:chapterId", element: <RequireAuth><RequireFeature feature="shortDramaEnabled">{deferred(<ProjectDetailPage />)}</RequireFeature></RequireAuth> },
{ path: "/canvas", element: <RequireAuth>{deferred(<CanvasPage />)}</RequireAuth> },
{ path: "/canvas/:id", element: <RequireAuth>{deferred(<CanvasProjectPage />)}</RequireAuth> },
{
path: "/admin",
element: <RequireAuth><AdminPage /></RequireAuth>,
element: <RequireAuth>{deferred(<AdminPage />)}</RequireAuth>,
children: [
{ index: true, element: <AnalyticsPage /> },
{ path: "users", element: <UsersPage /> },
{ path: "channels", element: <ChannelsPage /> },
{ path: "prompt-templates", element: <StoryboardPromptsPage /> },
{ index: true, element: deferred(<AnalyticsPage />) },
{ path: "users", element: deferred(<UsersPage />) },
{ path: "channels", element: deferred(<ChannelsPage />) },
{ path: "prompt-templates", element: deferred(<StoryboardPromptsPage />) },
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
{ path: "announcements", element: <AnnouncementsPage /> },
{ path: "credit-operations", element: <CreditOperationsPage /> },
{ path: "redemption-codes", element: <RedemptionCodesPage /> },
{ path: "logs", element: <LogsPage /> },
{ path: "announcements", element: deferred(<AnnouncementsPage />) },
{ path: "credit-operations", element: deferred(<CreditOperationsPage />) },
{ path: "redemption-codes", element: deferred(<RedemptionCodesPage />) },
{ path: "logs", element: deferred(<LogsPage />) },
{ path: "settings", element: <Navigate to="runtime-policy" replace /> },
{ path: "settings/drawing-engine", element: <DrawingEngineSettingsPage /> },
{ path: "settings/drawing-engine", element: deferred(<DrawingEngineSettingsPage />) },
{ path: "settings/concurrency", element: <Navigate to="/admin/settings/runtime-policy" replace /> },
{ path: "settings/runtime-policy", element: <RuntimePolicySettingsPage /> },
{ path: "settings/features", element: <FeatureAvailabilityPage /> },
{ path: "settings/access", element: <AccessSettingsPage /> },
{ path: "settings/email", element: <EmailSettingsPage /> },
{ path: "settings/storage", element: <StorageSettingsPage /> },
{ path: "settings/runtime-policy", element: deferred(<RuntimePolicySettingsPage />) },
{ path: "settings/features", element: deferred(<FeatureAvailabilityPage />) },
{ path: "settings/access", element: deferred(<AccessSettingsPage />) },
{ path: "settings/email", element: deferred(<EmailSettingsPage />) },
{ path: "settings/storage", element: deferred(<StorageSettingsPage />) },
],
},
],
},
{ path: "*", element: <NotFound /> },
{ path: "*", element: deferred(<NotFound />) },
]);

View File

@@ -12,6 +12,14 @@ export type RemoteUserDataSummary = {
updatedAt: string;
};
export type RemoteUserDataSnapshot = {
assets: Asset[];
projects: CanvasProject[];
};
export function getRemoteUserDataSnapshot() {
return request<RemoteUserDataSnapshot>(api.get("/user-data/snapshot"));
}
export function listRemoteAssets() {
return request<{ assets: RemoteUserDataSummary[] }>(api.get("/assets"));

View File

@@ -2,7 +2,7 @@ import localforage from "localforage";
import { nanoid } from "nanoid";
import { getActiveUserScope } from "@/lib/user-scope";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { getCachedResourceBlob, getCachedResourceObjectUrl, primeResourceBlobCache } from "@/services/resource-blob-cache";
export type UploadedFile = { url: string; storageKey: string; bytes: number; mimeType: string; width?: number; height?: number; durationMs?: number };
@@ -32,9 +32,10 @@ export async function uploadMediaFile(input: string | Blob, prefix = "file"): Pr
export async function resolveMediaUrl(storageKey?: string, fallback = "") {
if (!storageKey) return fallback;
if (resourceIdFromStorageKey(storageKey)) {
const resourceId = resourceIdFromStorageKey(storageKey);
if (resourceId) {
const cached = await getCachedResourceObjectUrl(storageKey).catch(() => "");
return cached || resolveResourceUrl(storageKey, fallback);
return cached || resourceFileUrl(resourceId);
}
const cached = objectUrls.get(storageKey);
if (cached) return cached;

View File

@@ -3,7 +3,7 @@ import localforage from "localforage";
import { nanoid } from "nanoid";
import { readImageMeta } from "@/lib/image-utils";
import { getActiveUserScope } from "@/lib/user-scope";
import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources";
import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { cacheResourceObjectUrl, getCachedResourceBlob, getCachedResourceObjectUrl, primeResourceBlobCache } from "@/services/resource-blob-cache";
export type UploadedImage = {
@@ -65,14 +65,15 @@ function shouldImportRemoteImage(input: string) {
export async function resolveImageUrl(storageKey?: string, fallback = "", options?: { cacheMiss?: boolean }) {
if (!storageKey) return fallback;
if (resourceIdFromStorageKey(storageKey)) {
const resourceId = resourceIdFromStorageKey(storageKey);
if (resourceId) {
const cached = await getCachedResourceObjectUrl(storageKey).catch(() => "");
if (cached) return cached;
if (options?.cacheMiss) {
const populated = await cacheResourceObjectUrl(storageKey).catch(() => "");
if (populated) return populated;
}
return resolveResourceUrl(storageKey, fallback);
return resourceFileUrl(resourceId);
}
const cached = objectUrls.get(storageKey);
if (cached) return cached;

View File

@@ -1,6 +1,6 @@
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob, resolveImageUrl } from "@/services/image-storage";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteCanvasProject, listRemoteAssets, listRemoteCanvasProjects, upsertRemoteAsset, upsertRemoteCanvasProject, type RemoteUserDataSummary } from "@/services/api/user-data";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import type { Asset } from "@/stores/use-asset-store";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -23,17 +23,13 @@ export async function syncRemoteUserData(userId?: string | null) {
if (!activeRemoteUserId) return;
applyingRemoteState = true;
try {
const [remoteCanvas, remoteAssets] = await Promise.all([listRemoteCanvasProjects(), listRemoteAssets()]);
remoteProjectVersions = versionMap(remoteCanvas.projects);
remoteAssetVersions = versionMap(remoteAssets.assets);
const remoteSnapshot = await getRemoteUserDataSnapshot();
remoteProjectVersions = versionMap(remoteSnapshot.projects);
remoteAssetVersions = versionMap(remoteSnapshot.assets);
const localProjects = useCanvasStore.getState().projects;
const localAssets = useAssetStore.getState().assets;
const [changedProjects, changedAssets] = await Promise.all([
fetchNewerRemoteItems(localProjects, remoteCanvas.projects, async (id) => (await getRemoteCanvasProject(id)).project),
fetchNewerRemoteItems(localAssets, remoteAssets.assets, async (id) => (await getRemoteAsset(id)).asset),
]);
const mergedProjects = mergeById(localProjects, changedProjects);
const mergedAssets = mergeById(localAssets, await hydrateAssets(changedAssets));
const mergedProjects = mergeById(localProjects, remoteSnapshot.projects);
const mergedAssets = mergeById(localAssets, await hydrateAssets(remoteSnapshot.assets));
useCanvasStore.getState().replaceProjects(mergedProjects);
useAssetStore.getState().replaceAssets(mergedAssets);
} finally {
@@ -128,8 +124,8 @@ async function saveRemoteUserDataBatch() {
try {
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((item) => remoteProjectVersions.get(item.id) !== item.updatedAt);
const dirtyAssets = currentAssets.filter((item) => remoteAssetVersions.get(item.id) !== item.updatedAt);
const dirtyProjects = currentProjects.filter((item) => !sameVersion(remoteProjectVersions.get(item.id), item.updatedAt));
const dirtyAssets = currentAssets.filter((item) => !sameVersion(remoteAssetVersions.get(item.id), item.updatedAt));
const deletedProjectIds = missingIds(remoteProjectVersions, currentProjects);
const deletedAssetIds = missingIds(remoteAssetVersions, currentAssets);
if (!dirtyProjects.length && !dirtyAssets.length && !deletedProjectIds.length && !deletedAssetIds.length) return;
@@ -278,17 +274,8 @@ function mergeById<T extends { id?: string; updatedAt?: string }>(local: T[], re
return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt));
}
async function fetchNewerRemoteItems<T extends { id: string; updatedAt?: string }>(local: T[], remote: RemoteUserDataSummary[], fetchItem: (id: string) => Promise<T>) {
const localById = new Map(local.map((item) => [item.id, item]));
const pending = remote.filter((item) => {
const current = localById.get(item.id);
return !current || timeValue(item.updatedAt) > timeValue(current.updatedAt);
});
return Promise.all(pending.map((item) => fetchItem(item.id)));
}
function versionMap(items: RemoteUserDataSummary[]) {
return new Map(items.map((item) => [item.id, item.updatedAt]));
function versionMap(items: Array<{ id: string; updatedAt?: string }>) {
return new Map<string, string>(items.map((item) => [item.id, item.updatedAt || ""]));
}
function missingIds<T extends { id: string }>(remote: Map<string, string>, local: T[]) {
@@ -306,6 +293,10 @@ function timeValue(value?: string) {
return Number.isFinite(time) ? time : 0;
}
function sameVersion(remote?: string, local?: string) {
return Boolean(remote && local) && timeValue(remote) === timeValue(local);
}
function isLocalStorageKey(value: string) {
return LOCAL_STORAGE_KEY_PATTERN.test(value) && !resourceIdFromStorageKey(value);
}