Files
makelore/src/stores/plugin-marketplace.ts

348 lines
13 KiB
TypeScript

import { createStore, type StoreApi } from 'zustand';
import { useStore } from 'zustand';
import {
acquireMarketplacePlugin,
installBetaMarketplacePlugin,
installMarketplacePlugin,
readMarketplaceCatalog,
readMarketplaceDetail,
readMarketplaceLibrary,
removeMarketplacePlugin,
uninstallMarketplacePlugin,
updateMarketplacePlugin,
type MarketplaceCatalogPage,
type MarketplaceCatalogQuery,
type MarketplaceInstallation,
type MarketplaceLibraryProjection,
type MarketplaceLibrarySnapshot,
type MarketplacePluginDetail,
} from '@/lib/plugin-marketplace';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export type PluginMarketplaceDependencies = {
readCatalog(input?: MarketplaceCatalogQuery): Promise<MarketplaceCatalogPage>;
readDetail(pluginId: string): Promise<MarketplacePluginDetail>;
readLibrary(): Promise<MarketplaceLibraryProjection>;
acquire(pluginId: string): Promise<MarketplaceLibraryProjection>;
remove(pluginId: string): Promise<MarketplaceLibraryProjection>;
install(pluginId: string): Promise<MarketplaceInstallation>;
installBeta(pluginId: string): Promise<MarketplaceInstallation>;
update(pluginId: string): Promise<MarketplaceInstallation>;
uninstall(pluginId: string): Promise<MarketplaceInstallation>;
};
export type PluginMarketplaceState = {
accountKey: string | null;
catalog: MarketplaceCatalogPage | null;
catalogState: LoadState;
catalogError: string | null;
catalogQuery: MarketplaceCatalogQuery;
library: MarketplaceLibrarySnapshot | null;
libraryState: LoadState;
libraryError: string | null;
installations: Record<string, MarketplaceInstallation>;
details: Record<string, MarketplacePluginDetail>;
detailState: Record<string, LoadState>;
pending: Record<string, true>;
activateAccount(accountKey: string | null): void;
loadCatalog(input?: MarketplaceCatalogQuery): Promise<void>;
loadDetail(pluginId: string): Promise<void>;
loadLibrary(): Promise<void>;
acquire(pluginId: string): Promise<void>;
remove(pluginId: string): Promise<void>;
install(pluginId: string): Promise<void>;
installBeta(pluginId: string): Promise<void>;
update(pluginId: string): Promise<void>;
uninstall(pluginId: string): Promise<void>;
};
const defaults: PluginMarketplaceDependencies = {
readCatalog: readMarketplaceCatalog,
readDetail: readMarketplaceDetail,
readLibrary: readMarketplaceLibrary,
acquire: acquireMarketplacePlugin,
remove: removeMarketplacePlugin,
install: installMarketplacePlugin,
installBeta: installBetaMarketplacePlugin,
update: updateMarketplacePlugin,
uninstall: uninstallMarketplacePlugin,
};
function message(error: unknown): string {
const base = error instanceof Error ? error.message : String(error);
if (!error || typeof error !== 'object') return base;
const errorCode = 'code' in error && typeof error.code === 'string' ? error.code : null;
const details = 'details' in error && error.details && typeof error.details === 'object'
? error.details as Record<string, unknown>
: null;
const backendCode = details?.backendCode;
const prefixed = typeof backendCode === 'string' ? `${backendCode}: ${base}` : base;
return errorCode && !prefixed.includes(errorCode) ? `${errorCode}: ${prefixed}` : prefixed;
}
function installationMap(items: readonly MarketplaceInstallation[]): Record<string, MarketplaceInstallation> {
return Object.fromEntries(items.map((item) => [item.pluginId, item]));
}
export function createPluginMarketplaceStore(
overrides: Partial<PluginMarketplaceDependencies> = {},
): StoreApi<PluginMarketplaceState> {
const deps = { ...defaults, ...overrides };
let scopeGeneration = 0;
let catalogGeneration = 0;
let libraryIntent = 0;
let latestLibraryReadIntent = 0;
const latestLibraryMutationIntent = new Map<string, number>();
const latestDeviceMutationIntent = new Map<string, number>();
return createStore<PluginMarketplaceState>((set, get) => {
const beginMutation = (pluginId: string, domain: 'library' | 'device'): number => {
const intent = ++libraryIntent;
(domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).set(pluginId, intent);
return intent;
};
const canCommitMutation = (
pluginId: string,
intent: number,
generation: number,
accountKey: string,
domain: 'library' | 'device',
): boolean => generation === scopeGeneration
&& get().accountKey === accountKey
&& (domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).get(pluginId) === intent
// A later explicit read is the newest Library projection, so an older
// Library mutation must not replace it. Device state is independent.
&& (domain === 'device' || latestLibraryReadIntent <= intent);
const latestPluginMutationIntent = (pluginId: string): number => Math.max(
latestLibraryMutationIntent.get(pluginId) ?? 0,
latestDeviceMutationIntent.get(pluginId) ?? 0,
);
const mergeLibraryForPlugin = (
current: MarketplaceLibrarySnapshot | null,
incoming: MarketplaceLibrarySnapshot,
pluginId: string,
): MarketplaceLibrarySnapshot => {
if (!current) return incoming;
const byPlugin = new Map(current.items.map((item) => [item.pluginId, item]));
const incomingItem = incoming.items.find((item) => item.pluginId === pluginId);
if (incomingItem) byPlugin.set(pluginId, incomingItem);
else byPlugin.delete(pluginId);
const items = [...byPlugin.values()].sort((left, right) => {
const leftIntent = latestPluginMutationIntent(left.pluginId);
const rightIntent = latestPluginMutationIntent(right.pluginId);
return rightIntent - leftIntent;
});
return {
...incoming,
items,
total: Math.max(current.total, incoming.total, items.length),
fetchedAt: Math.max(current.fetchedAt, incoming.fetchedAt),
};
};
const mergeLibraryProjection = (
pluginId: string,
projection: MarketplaceLibraryProjection,
): Pick<PluginMarketplaceState, 'library' | 'installations'> => {
const state = get();
const library = mergeLibraryForPlugin(state.library, projection.library, pluginId);
const incomingInstallation = projection.installations.find((item) => item.pluginId === pluginId);
const installations = { ...state.installations };
if (incomingInstallation) installations[pluginId] = incomingInstallation;
return { library, installations };
};
const pending = async <T>(key: string, operation: () => Promise<T>): Promise<T> => {
set((state) => ({ pending: { ...state.pending, [key]: true } }));
try {
return await operation();
} finally {
set((state) => {
const next = { ...state.pending };
delete next[key];
return { pending: next };
});
}
};
const libraryAction = async (
key: string,
pluginId: string,
action: (pluginId: string) => Promise<MarketplaceLibraryProjection>,
): Promise<void> => pending(key, async () => {
const generation = scopeGeneration;
const accountKey = get().accountKey;
if (!accountKey) throw new Error('请先登录后管理我的插件');
const intent = beginMutation(pluginId, 'library');
try {
const projection = await action(pluginId);
if (!canCommitMutation(pluginId, intent, generation, accountKey, 'library')) return;
const merged = mergeLibraryProjection(pluginId, projection);
set({
library: merged.library,
installations: merged.installations,
libraryState: 'ready',
libraryError: null,
});
} catch (error) {
if (canCommitMutation(pluginId, intent, generation, accountKey, 'library')) {
set({ libraryError: message(error) });
}
throw error;
}
});
const installAction = async (
key: string,
pluginId: string,
action: (pluginId: string) => Promise<MarketplaceInstallation>,
): Promise<void> => pending(key, async () => {
const generation = scopeGeneration;
const accountKey = get().accountKey;
if (!accountKey) throw new Error('请先登录后管理设备插件');
const intent = beginMutation(pluginId, 'device');
try {
const result = await action(pluginId);
if (!canCommitMutation(pluginId, intent, generation, accountKey, 'device')) return;
set((state) => {
const installations = { ...state.installations };
if (result.status === 'removed') delete installations[pluginId];
else installations[pluginId] = result;
return { installations, libraryError: null };
});
} catch (error) {
if (canCommitMutation(pluginId, intent, generation, accountKey, 'device')) {
const reason = message(error);
set((state) => ({
installations: {
...state.installations,
[pluginId]: {
status: 'unavailable',
pluginId,
...(state.installations[pluginId]?.releaseId
? { releaseId: state.installations[pluginId].releaseId }
: {}),
...(state.installations[pluginId]?.version
? { version: state.installations[pluginId].version }
: {}),
reason,
},
},
libraryError: reason,
}));
}
throw error;
}
});
return {
accountKey: null,
catalog: null,
catalogState: 'idle',
catalogError: null,
catalogQuery: {},
library: null,
libraryState: 'idle',
libraryError: null,
installations: {},
details: {},
detailState: {},
pending: {},
activateAccount(accountKey) {
if (get().accountKey === accountKey) return;
scopeGeneration += 1;
libraryIntent += 1;
latestLibraryReadIntent = libraryIntent;
latestLibraryMutationIntent.clear();
latestDeviceMutationIntent.clear();
set({
accountKey,
library: null,
libraryState: 'idle',
libraryError: null,
installations: {},
pending: {},
});
},
async loadCatalog(input = {}) {
const generation = ++catalogGeneration;
set({ catalogState: 'loading', catalogError: null, catalogQuery: input });
try {
const catalog = await deps.readCatalog(input);
if (generation === catalogGeneration) set({ catalog, catalogState: 'ready', catalogError: null });
} catch (error) {
if (generation === catalogGeneration) set({ catalogState: 'error', catalogError: message(error) });
throw error;
}
},
async loadDetail(pluginId) {
const generation = scopeGeneration;
set((state) => ({ detailState: { ...state.detailState, [pluginId]: 'loading' } }));
try {
const detail = await deps.readDetail(pluginId);
if (generation !== scopeGeneration) return;
set((state) => ({
details: { ...state.details, [pluginId]: detail },
detailState: { ...state.detailState, [pluginId]: 'ready' },
}));
} catch (error) {
if (generation === scopeGeneration) {
set((state) => ({
detailState: { ...state.detailState, [pluginId]: 'error' },
catalogError: message(error),
}));
}
throw error;
}
},
async loadLibrary() {
const readIntent = ++libraryIntent;
latestLibraryReadIntent = readIntent;
const generation = scopeGeneration;
const accountKey = get().accountKey;
if (!accountKey) {
set({ library: null, installations: {}, libraryState: 'idle', libraryError: null });
return;
}
set({ libraryState: 'loading', libraryError: null });
try {
const projection = await deps.readLibrary();
if (generation === scopeGeneration && get().accountKey === accountKey
&& readIntent === latestLibraryReadIntent
&& readIntent === libraryIntent) {
set({
library: projection.library,
installations: installationMap(projection.installations),
libraryState: 'ready',
libraryError: null,
});
}
} catch (error) {
if (generation === scopeGeneration && get().accountKey === accountKey
&& readIntent === latestLibraryReadIntent
&& readIntent === libraryIntent) {
set({ libraryState: 'error', libraryError: message(error) });
}
throw error;
}
},
acquire: (pluginId) => libraryAction(`acquire:${pluginId}`, pluginId, deps.acquire),
remove: (pluginId) => libraryAction(`remove:${pluginId}`, pluginId, deps.remove),
install: (pluginId) => installAction(`install:${pluginId}`, pluginId, deps.install),
installBeta: (pluginId) => installAction(`install-beta:${pluginId}`, pluginId, deps.installBeta),
update: (pluginId) => installAction(`update:${pluginId}`, pluginId, deps.update),
uninstall: (pluginId) => installAction(`uninstall:${pluginId}`, pluginId, deps.uninstall),
};
});
}
export const pluginMarketplaceStore = createPluginMarketplaceStore();
export function usePluginMarketplaceStore<T>(selector: (state: PluginMarketplaceState) => T): T {
return useStore(pluginMarketplaceStore, selector);
}