fix(plugin): serialize project refresh mutations

This commit is contained in:
2026-08-27 21:42:33 +08:00
parent 405b9f64fd
commit e3e34d3803
3 changed files with 283 additions and 27 deletions

View File

@@ -118,6 +118,162 @@ describe('coding plugins store', () => {
});
});
it('does not let an older same-project load overwrite a completed enable mutation', async () => {
const staleLoad = deferred<ReturnType<typeof projection>>();
const store = createCodingPluginsStore({
list: vi.fn(() => staleLoad.promise),
setEnabled: vi.fn().mockResolvedValue(projection(true)),
});
store.setState({ projectId: 'local-project', projection: projection(false) });
const load = store.getState().load('local-project');
await store.getState().setEnabled('local-project', 'makelore.data-service', true);
staleLoad.resolve(projection(false));
await load;
expect(store.getState().projection?.items[0].enabled).toBe(true);
});
it('commits a delayed enable after a same-project refresh observes old state', async () => {
const enabled = deferred<ReturnType<typeof projection>>();
const store = createCodingPluginsStore({
list: vi.fn().mockResolvedValue(projection(false)),
setEnabled: vi.fn(() => enabled.promise),
});
store.setState({ projectId: 'local-project', projection: projection(false) });
const mutation = store.getState().setEnabled('local-project', 'makelore.data-service', true);
await store.getState().load('local-project');
expect(store.getState().projection?.items[0].enabled).toBe(false);
enabled.resolve(projection(true));
await mutation;
expect(store.getState().projection?.items[0].enabled).toBe(true);
});
const adjacentMutationCases = [
{
name: 'configure',
verify: async () => {
const configured = deferred<DataServiceHostResult<DataServiceInstanceState>>();
let loadCount = 0;
const list = vi.fn(async () => projection(++loadCount > 1));
const store = createCodingPluginsStore({
list,
configureDataService: vi.fn(() => configured.promise),
});
store.setState({ projectId: 'local-project', projection: projection(false) });
const mutation = store.getState().configure(['todos']);
await store.getState().load('local-project');
configured.resolve(success(dataServiceInstance('configured')));
await mutation;
expect(list).toHaveBeenCalledTimes(2);
expect(store.getState()).toMatchObject({
projection: { items: [{ enabled: true }] },
dataService: { instance_id: 'configured' },
});
},
},
{
name: 'reset',
verify: async () => {
const reset = deferred<DataServiceHostResult<DataServiceInstanceState>>();
const store = createCodingPluginsStore({
list: vi.fn().mockResolvedValue(projection(false)),
resetDataService: vi.fn(() => reset.promise),
});
store.setState({
projectId: 'local-project', projection: projection(false),
dataService: dataServiceInstance('before-reset'),
});
const mutation = store.getState().reset();
await store.getState().load('local-project');
reset.resolve(success(dataServiceInstance('after-reset')));
await mutation;
expect(store.getState().dataService?.instance_id).toBe('after-reset');
},
},
{
name: 'removeCollection',
verify: async () => {
const removed = deferred<DataServiceHostResult<DataServiceCollectionRemoval>>();
const inspectDataService = vi.fn().mockResolvedValue(success(dataServiceInstance('after-remove')));
const store = createCodingPluginsStore({
list: vi.fn().mockResolvedValue(projection(false)),
removeCollection: vi.fn(() => removed.promise),
inspectDataService,
});
store.setState({
projectId: 'local-project', projection: projection(false),
dataService: dataServiceInstance('before-remove'),
});
const mutation = store.getState().removeCollection('todos');
await store.getState().load('local-project');
removed.resolve(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } }));
await mutation;
expect(inspectDataService).toHaveBeenCalledOnce();
expect(store.getState().dataService?.instance_id).toBe('after-remove');
},
},
{
name: 'removeProject',
verify: async () => {
const removed = deferred<DataServiceHostResult<DataServiceInstanceRemoval>>();
const list = vi.fn().mockResolvedValue(projection(false));
const store = createCodingPluginsStore({
list,
removeProject: vi.fn(() => removed.promise),
});
store.setState({
projectId: 'local-project', projection: projection(false),
dataService: dataServiceInstance('before-remove-project'),
});
const mutation = store.getState().removeProject();
await store.getState().load('local-project');
removed.resolve(success({ removed: true }));
await mutation;
expect(list).toHaveBeenCalledTimes(2);
expect(store.getState().dataService).toBeNull();
},
},
];
it.each(adjacentMutationCases)(
'uses the same same-project ordering guard for $name',
async ({ verify }) => await verify(),
);
it('reloads the newest projection when collection removal succeeds but inspection fails', async () => {
const readyProjection = projection(true);
readyProjection.items[0] = {
...readyProjection.items[0], state: 'ready', backend: { status: 'ready' },
};
const afterRemoval = dataServiceInstance('after-remove-reload');
const inspectDataService = vi.fn()
.mockResolvedValueOnce({
success: false, status: 503, code: 'offline', error: 'offline', retryable: true, data: null,
})
.mockResolvedValueOnce(success(afterRemoval));
const list = vi.fn().mockResolvedValue(readyProjection);
const store = createCodingPluginsStore({
list,
inspectDataService,
removeCollection: vi.fn().mockResolvedValue(success({
removed: true, usage: { document_count: 0, total_bytes: 0 },
})),
});
store.setState({
projectId: 'local-project', projection: readyProjection,
dataService: dataServiceInstance('before-remove-reload'),
});
await store.getState().removeCollection('todos');
expect(list).toHaveBeenCalledOnce();
expect(inspectDataService).toHaveBeenCalledTimes(2);
expect(store.getState().dataService).toEqual(afterRemoval);
});
it('does not let a late project A enable mutation overwrite loaded project B', async () => {
const enabledA = deferred<ReturnType<typeof projection>>();
const inspectDataService = vi.fn();