FN-5796: make plugin same-state transitions idempotent
Prevent duplicate-state plugin updates from throwing invalid transition errors. - treat updatePluginState calls where next state equals current state as idempotent no-ops - allow same-state updates with an explicit error payload to persist error changes and emit plugin:updated - add regression tests for same-state no-op behavior, no extra stateChanged event emission, and error payload updates - add a patch changeset documenting the Dependency Graph plugin fix Files changed: .changeset/fn-5796-plugin-state-noop.md | 7 +++++ packages/core/src/__tests__/plugin-store.test.ts | 34 ++++++++++++++++++++++++ packages/core/src/plugin-store.ts | 15 +++++++++++ 3 files changed, 56 insertions(+) Fusion-Task-Id: FN-5796 Fusion-Task-Lineage: 0da1d035-383f-456c-a212-0903dc029874
This commit is contained in:
@@ -663,6 +663,40 @@ describe("PluginStore", () => {
|
||||
).rejects.toThrow("Invalid state transition");
|
||||
});
|
||||
|
||||
it("treats same-state transitions as no-op", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
|
||||
await expect(store.updatePluginState("test-plugin", "started")).resolves.toMatchObject({
|
||||
id: "test-plugin",
|
||||
state: "started",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit plugin:stateChanged for same-state transitions", async () => {
|
||||
const stateChanged = vi.fn();
|
||||
store.on("plugin:stateChanged", stateChanged);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
expect(stateChanged).toHaveBeenCalledTimes(1);
|
||||
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
expect(stateChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates error on same-state transition when error payload is provided", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
|
||||
const plugin = await store.updatePluginState("test-plugin", "started", "Recovered warning");
|
||||
expect(plugin.state).toBe("started");
|
||||
expect(plugin.error).toBe("Recovered warning");
|
||||
});
|
||||
|
||||
it("allows restarting from stopped", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
@@ -487,6 +487,21 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
throw new Error(`Invalid state: ${state}`);
|
||||
}
|
||||
|
||||
if (state === oldState) {
|
||||
// Same-state transitions are idempotent by design. Only emit plugin:updated
|
||||
// when a provided error payload actually changes persisted plugin fields.
|
||||
if (error === undefined || plugin.error === error) {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
this.upsertProjectState(id, { state, error });
|
||||
this.centralDb.bumpLastModified();
|
||||
|
||||
const updated = await this.getPlugin(id);
|
||||
this.emit("plugin:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
if (state !== "error") {
|
||||
const validTransitions: Record<PluginState, PluginState[]> = {
|
||||
installed: ["started", "stopped", "error"],
|
||||
|
||||
Reference in New Issue
Block a user