/** * 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; } 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(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; }>(); private readonly libraryIntents = new Map(); 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(), }; 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(), }; 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 { const result = new Set(); 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;