fix(marketplace): close release lifecycle gaps
This commit is contained in:
@@ -100,7 +100,7 @@ export interface InstalledReleaseRecord {
|
||||
readonly sha256: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly installedAt: string;
|
||||
/** Channel and verified client range are immutable facts of this install. */
|
||||
/** Channel is the latest explicit selection intent; verified client range is immutable. */
|
||||
readonly channel?: 'stable' | 'beta';
|
||||
readonly minMakeloreVersion?: string;
|
||||
readonly maxMakeloreVersion?: string | null;
|
||||
@@ -628,6 +628,7 @@ export class PluginPackageStore {
|
||||
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
|
||||
private readonly activeWorkers = new Set<string>();
|
||||
private readonly pendingExplicitCleanupPluginIds = new Set<string>();
|
||||
private readonly unsubscribeSession: (() => void) | null;
|
||||
private operation: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -691,7 +692,11 @@ export class PluginPackageStore {
|
||||
fail('plugin_beta_selection_required');
|
||||
}
|
||||
const binding = this.requireBinding();
|
||||
return this.withOperation(() => this.resolveAndInstallLocked(input, binding));
|
||||
return this.withOperation(async () => {
|
||||
const result = await this.resolveAndInstallLocked(input, binding);
|
||||
this.pendingExplicitCleanupPluginIds.delete(input.pluginId);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
||||
@@ -714,7 +719,13 @@ export class PluginPackageStore {
|
||||
this.assertBinding(binding);
|
||||
this.accountCache.invalidatePlugin(binding, validated);
|
||||
this.assertBinding(binding);
|
||||
return this.removeUnusedLocked(validated, binding, 'explicit');
|
||||
const result = await this.removeUnusedLocked(validated, binding, 'explicit');
|
||||
if (result.status === 'kept' && result.reason === 'active_worker_reference') {
|
||||
this.pendingExplicitCleanupPluginIds.add(validated);
|
||||
} else {
|
||||
this.pendingExplicitCleanupPluginIds.delete(validated);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -722,8 +733,15 @@ export class PluginPackageStore {
|
||||
this.activeWorkers.add(validReleaseId(releaseId));
|
||||
}
|
||||
|
||||
releaseActiveWorker(releaseId: string): void {
|
||||
async releaseActiveWorker(releaseId: string): Promise<void> {
|
||||
this.activeWorkers.delete(validReleaseId(releaseId));
|
||||
if (this.pendingExplicitCleanupPluginIds.size === 0) return;
|
||||
await this.withOperation(async () => {
|
||||
for (const pluginId of [...this.pendingExplicitCleanupPluginIds]) {
|
||||
const result = await this.removeUnusedLocked(pluginId, undefined, 'explicit');
|
||||
if (result.status === 'removed') this.pendingExplicitCleanupPluginIds.delete(pluginId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
|
||||
@@ -788,12 +806,13 @@ export class PluginPackageStore {
|
||||
if (item.action === 'keep') {
|
||||
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
||||
this.assertBinding(binding);
|
||||
await this.persistSelectedChannel(index, pluginId, current.releaseId, channel, binding);
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId,
|
||||
releaseId: current.releaseId,
|
||||
version: current.version,
|
||||
...(current.channel === undefined ? {} : { channel: current.channel }),
|
||||
channel,
|
||||
packageRoot: current.packageRoot,
|
||||
definition: current.definition,
|
||||
};
|
||||
@@ -814,6 +833,7 @@ export class PluginPackageStore {
|
||||
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
|
||||
if (existing && !existing.unavailableReason) {
|
||||
this.assertBinding(binding);
|
||||
await this.persistSelectedChannel(index, pluginId, existing.releaseId, channel, binding);
|
||||
await this.setCurrentSelection(
|
||||
await this.readCurrentSelection(),
|
||||
pluginId,
|
||||
@@ -824,7 +844,7 @@ export class PluginPackageStore {
|
||||
pluginId,
|
||||
releaseId: existing.releaseId,
|
||||
version: existing.version,
|
||||
...(existing.channel === undefined ? {} : { channel: existing.channel }),
|
||||
channel,
|
||||
packageRoot: existing.packageRoot,
|
||||
definition: existing.definition,
|
||||
};
|
||||
@@ -1016,6 +1036,33 @@ export class PluginPackageStore {
|
||||
});
|
||||
}
|
||||
|
||||
private async persistSelectedChannel(
|
||||
index: IndexDocument,
|
||||
pluginId: string,
|
||||
releaseId: string,
|
||||
channel: 'stable' | 'beta',
|
||||
binding: AccountBinding,
|
||||
): Promise<void> {
|
||||
const record = index.releases.find((candidate) => (
|
||||
candidate.pluginId === pluginId && candidate.releaseId === releaseId
|
||||
));
|
||||
if (!record) fail('plugin_store_index_invalid', 'selected Release is missing from the package index');
|
||||
if (record.channel === channel) return;
|
||||
this.assertBinding(binding);
|
||||
const releases = index.releases.map((candidate) => (
|
||||
candidate === record ? Object.freeze({ ...candidate, channel }) : candidate
|
||||
));
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({
|
||||
schema_version: INDEX_SCHEMA_VERSION,
|
||||
releases,
|
||||
}));
|
||||
} catch {
|
||||
throw new PluginPackageStoreError('plugin_install_failed', 'package channel update failed');
|
||||
}
|
||||
this.assertBinding(binding);
|
||||
}
|
||||
|
||||
private async removeUnusedLocked(
|
||||
validated: string,
|
||||
binding?: AccountBinding,
|
||||
|
||||
Reference in New Issue
Block a user