248 lines
8.3 KiB
TypeScript
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;
|