Files
makelore/electron/coding-plugins/account-plugin-cache.ts

248 lines
8.3 KiB
TypeScript

/**
* Session/account-scoped Marketplace projections.
*
* The package index is deliberately device-scoped and contains immutable
* release facts only. Library rows and release admissions live here instead,
* keyed by the opaque Main-owned account binding and its session epoch.
*/
export interface AccountBinding {
readonly accountKey: string;
readonly epoch: number;
}
export type MarketplaceChannel = 'stable' | 'beta';
export type MarketplaceResolveAction = 'keep' | 'install' | 'update' | 'unavailable';
export interface MarketplaceLibraryEntry {
readonly pluginId: string;
readonly title: string;
readonly summary: string;
readonly category: string;
readonly acquisition: 'free' | 'system_included';
readonly acquisitionMode: 'system_included' | 'user_acquired';
readonly catalogStatus: 'active' | 'retired';
readonly runtimeStatus: 'enabled' | 'suspended';
readonly acquiredAt: string | null;
readonly removedAt: string | null;
readonly stableVersion: string | null;
readonly betaVersion: string | null;
}
export interface MarketplaceLibrarySnapshot {
readonly items: readonly MarketplaceLibraryEntry[];
readonly total: number;
readonly stale: boolean;
readonly fetchedAt: number;
}
export interface MarketplaceResolveItem {
readonly pluginId: string;
readonly action: MarketplaceResolveAction;
readonly releaseId?: string | null;
readonly version?: string | null;
readonly sha256?: string | null;
readonly sizeBytes?: number | null;
readonly releaseAdmissionId?: string | null;
readonly expiresAt?: string | null;
readonly channel?: MarketplaceChannel | null;
readonly reason?: string | null;
}
export interface MarketplaceResolveSnapshot {
readonly resolveRequestId: string;
readonly resolveRequestDigest: string;
readonly items: readonly MarketplaceResolveItem[];
readonly catalogGeneration: number;
readonly etag: string | null;
readonly stale: boolean;
}
export interface AccountPluginCacheRecord {
readonly binding: AccountBinding;
readonly library: MarketplaceLibrarySnapshot | null;
readonly resolves: ReadonlyMap<string, MarketplaceResolveSnapshot>;
}
function assertBinding(binding: AccountBinding): void {
if (typeof binding.accountKey !== 'string' || binding.accountKey.length === 0
|| binding.accountKey.length > 512 || !Number.isSafeInteger(binding.epoch)
|| binding.epoch < 0) {
throw new TypeError('Marketplace account binding is invalid');
}
}
function bindingId(binding: AccountBinding): string {
assertBinding(binding);
return `${binding.accountKey}\u0000${binding.epoch}`;
}
function clone<T>(value: T): T {
return structuredClone(value);
}
/**
* In-memory account cache. Keeping this cache in Main memory prevents
* admissions and Library state from entering the shared package index or a
* project file. A session change invalidates every account snapshot.
*/
export class AccountPluginCache {
private readonly records = new Map<string, {
binding: AccountBinding;
library: MarketplaceLibrarySnapshot | null;
resolves: Map<string, MarketplaceResolveSnapshot>;
}>();
private readonly libraryIntents = new Map<string, number>();
private nextLibraryIntent(key: string): number {
const next = (this.libraryIntents.get(key) ?? 0) + 1;
this.libraryIntents.set(key, next);
return next;
}
private setLibrarySnapshot(key: string, binding: AccountBinding, snapshot: MarketplaceLibrarySnapshot): void {
const record = this.records.get(key) ?? {
binding: { accountKey: binding.accountKey, epoch: binding.epoch },
library: null,
resolves: new Map<string, MarketplaceResolveSnapshot>(),
};
record.library = clone(snapshot);
this.records.set(key, record);
}
getLibrary(binding: AccountBinding): MarketplaceLibrarySnapshot | null {
const record = this.records.get(bindingId(binding));
return record?.library ? clone(record.library) : null;
}
setLibrary(binding: AccountBinding, snapshot: MarketplaceLibrarySnapshot): void {
const key = bindingId(binding);
this.nextLibraryIntent(key);
this.setLibrarySnapshot(key, binding, snapshot);
}
/** Reserve the commit slot before an async Library read or mutation starts. */
beginLibraryIntent(binding: AccountBinding): number {
return this.nextLibraryIntent(bindingId(binding));
}
/** Commit only the latest read/mutation intent for this account binding. */
commitLibrary(
binding: AccountBinding,
intent: number,
snapshot: MarketplaceLibrarySnapshot,
): boolean {
const key = bindingId(binding);
if (this.libraryIntents.get(key) !== intent) return false;
this.setLibrarySnapshot(key, binding, snapshot);
return true;
}
markLibraryStale(binding: AccountBinding, intent?: number): MarketplaceLibrarySnapshot | null {
const key = bindingId(binding);
if (intent !== undefined && this.libraryIntents.get(key) !== intent) return null;
const record = this.records.get(key);
if (!record?.library) return null;
record.library = { ...record.library, stale: true };
return clone(record.library);
}
getResolve(binding: AccountBinding, logicalKey: string): MarketplaceResolveSnapshot | null {
if (typeof logicalKey !== 'string' || logicalKey.length === 0) return null;
const record = this.records.get(bindingId(binding));
const snapshot = record?.resolves.get(logicalKey);
return snapshot ? clone(snapshot) : null;
}
setResolve(
binding: AccountBinding,
logicalKey: string,
snapshot: MarketplaceResolveSnapshot,
): void {
if (typeof logicalKey !== 'string' || logicalKey.length === 0 || logicalKey.length > 512) {
throw new TypeError('Marketplace resolve cache key is invalid');
}
const key = bindingId(binding);
const record = this.records.get(key) ?? {
binding: { accountKey: binding.accountKey, epoch: binding.epoch },
library: null,
resolves: new Map<string, MarketplaceResolveSnapshot>(),
};
record.resolves.set(logicalKey, clone(snapshot));
this.records.set(key, record);
}
markResolveStale(binding: AccountBinding, logicalKey: string): MarketplaceResolveSnapshot | null {
const key = bindingId(binding);
const record = this.records.get(key);
const snapshot = record?.resolves.get(logicalKey);
if (!record || !snapshot) return null;
const stale = { ...snapshot, stale: true };
record.resolves.set(logicalKey, stale);
return clone(stale);
}
getRecord(binding: AccountBinding): AccountPluginCacheRecord | null {
const record = this.records.get(bindingId(binding));
if (!record) return null;
return {
binding: { ...record.binding },
library: record.library ? clone(record.library) : null,
resolves: new Map([...record.resolves.entries()].map(([key, value]) => [key, clone(value)])),
};
}
/** Release IDs retained by all live account snapshots. */
referencedReleaseIds(): ReadonlySet<string> {
const result = new Set<string>();
for (const record of this.records.values()) {
for (const snapshot of record.resolves.values()) {
for (const item of snapshot.items) {
if (item.releaseId) result.add(item.releaseId);
}
}
}
return result;
}
/**
* Forget device-resolution snapshots for one plugin in one account binding.
* The Library projection is intentionally retained so uninstalling a device
* package never becomes an account Library mutation.
*/
invalidatePlugin(binding: AccountBinding, pluginId: string): void {
const record = this.records.get(bindingId(binding));
if (!record) return;
for (const [key, snapshot] of record.resolves.entries()) {
const items = snapshot.items.filter((item) => item.pluginId !== pluginId);
if (items.length === 0) record.resolves.delete(key);
else if (items.length !== snapshot.items.length) record.resolves.set(key, { ...snapshot, items });
}
}
clearAccount(binding: AccountBinding): void {
assertBinding(binding);
for (const [key, record] of this.records.entries()) {
if (record.binding.accountKey === binding.accountKey) {
this.records.delete(key);
this.libraryIntents.delete(key);
}
}
}
invalidateAll(): void {
this.records.clear();
this.libraryIntents.clear();
}
clear(): void {
this.invalidateAll();
}
get size(): number {
return this.records.size;
}
}
export const accountBindingKey = bindingId;